From 5afb2d1875a90b085bddf97f9f751661eb3eb1e3 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 13 Aug 2026 15:46:00 +0530 Subject: [PATCH] fix(ui): make Pagination usable server-rendered, surface output errors Pagination reported the requested page only through its change output, so on a server-rendered page the controls did nothing: the parent had to own client state to react, and the emitted page never became a URL. An optional hrefTemplate now renders the steps and page numbers as anchors, which work before hydration and without JavaScript and give each page a crawlable URL. Buttons remain the default for client-owned lists. End steps are clamped and marked disabled rather than linking past the first or last page. Output handler errors are no longer swallowed. Nothing awaits invokeOutput, so a handler that threw became an unhandled rejection that never reached the console and presented as a control that silently does nothing. Handler errors are now reported as WRN-DEV-OUTPUT-HANDLER-ERROR. Regenerates the component reference and the UI visual contract. Co-Authored-By: Claude Opus 5 --- docs/ui-visual-contract-0.8.json | 8 +- packages/csr/src/outputs.ts | 38 ++++++++- packages/ui/COMPONENTS.md | 4 +- packages/ui/component-reference.json | 21 +++++ packages/ui/components/Pagination.wrn | 116 +++++++++++++++++++------- packages/ui/test/ui.test.ts | 58 +++++++++++++ 6 files changed, 210 insertions(+), 35 deletions(-) diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index b7ac6f43..f41f339d 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -39,7 +39,7 @@ "packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07", "packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8", "packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034", - "packages/ui/components/Footer.wrn": "6694302f63f44dda3527d97b1bcc3031d51362601835dbe842cb04ba70088758", + "packages/ui/components/Footer.wrn": "c498820a160c1286331a423a4498054e7852d2f1a9eb6e81eb5b008b1693efc4", "packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517", "packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900", "packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790", @@ -62,11 +62,11 @@ "packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a", "packages/ui/components/Navbar.wrn": "ed3bb1974b93d52c480af468bdb1c0b253cb9196f3a00602614d3ce1e271a669", "packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54", - "packages/ui/components/Pagination.wrn": "4c7ac4bbda38c791af503f7923a511a958adc01637d286bcaaaa0f00ae8bb0fd", + "packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39", "packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602", "packages/ui/components/Popover.wrn": "1246b99b4236bc7109a5d1427d013bb07bbea7a2da4f821517d2545ee0b5c019", "packages/ui/components/PortalDashboard.wrn": "f04171eb1f8cabef06de3c20e4694b766def3407e1e20d2c9aaebc9bb7da03ee", - "packages/ui/components/PreferenceSwitcher.wrn": "71fc4b2fd8897dbd2e0795a6a824d76a57a4ce8756a889662f26df28b73c411a", + "packages/ui/components/PreferenceSwitcher.wrn": "77229e15449182563ebf49159507139928eb74e799e4438e00e9aa81fd60f982", "packages/ui/components/PublicPageShell.wrn": "3d3f25c03d48f3d3d7e3c567e79cb137c7fce1867e054fd6d440fa1d2e961077", "packages/ui/components/RangeSlider.wrn": "bbf646c5d5a89a62986290db811d77b8f635ebcaea9ca2d334b569aa5edc785b", "packages/ui/components/Rating.wrn": "902b55c64e3798f3097f09b1558d1a2372f66d4f760662de085783108ee04587", @@ -88,7 +88,7 @@ "packages/ui/components/ToggleCount.wrn": "e577195ee05ddb43cceec47145f6b90c0149f2f17055733d58911fcd02de2c7f", "packages/ui/components/TogglePassword.wrn": "b6176453cb2c586fe88c3db6bdd734ae2b4c95db46d57e0de298dfba3d0dd0c2", "packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b", - "packages/ui/components/Typography.wrn": "a3de0febe0ba8640660e7f22babe8ca0e75e1b2882ba7be42f0728add7e05740", + "packages/ui/components/Typography.wrn": "f9c1a379febb050f33f8dbfdf2fa483e686921fa762dd50505d2b673bf2c22e1", "packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5", "packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d", "packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc", diff --git a/packages/csr/src/outputs.ts b/packages/csr/src/outputs.ts index aec6cc3d..da93c152 100644 --- a/packages/csr/src/outputs.ts +++ b/packages/csr/src/outputs.ts @@ -18,10 +18,46 @@ export function registerOutputHandler( }; } +/** + * Surface a throwing output handler. + * + * Callers of `invokeOutput` do not await it — an output call is fire-and-forget + * from the component's point of view. A handler that throws therefore becomes an + * unhandled rejection that never reaches the console, and the symptom is a + * control that silently does nothing. Reporting here turns that into a named + * diagnostic instead of a dead button. + */ +function reportOutputError(host: OutputHost, name: string, error: unknown): void { + const code = "WRN-DEV-OUTPUT-HANDLER-ERROR"; + const message = `The handler bound to output '${name}' threw. The control will appear to do nothing.`; + // eslint-disable-next-line no-console + console.error(`[${code}] ${message}`, error); + try { + window.dispatchEvent( + new CustomEvent("wrnexus:diagnostic", { + detail: { + code, + message, + hydrationId: host.getAttribute ? host.getAttribute("data-wrn-hydration") : null, + detail: { output: name, error: String(error) }, + }, + }), + ); + } catch { + // CustomEvent can be unavailable in minimal DOM test environments. + } +} + export async function invokeOutput(host: OutputHost, name: string, payload?: T): Promise { const handlers = host.__wrnexusOutputHandlers?.get(name); if (handlers?.size) { - for (const handler of handlers) await handler(payload); + for (const handler of handlers) { + try { + await handler(payload); + } catch (error) { + reportOutputError(host, name, error); + } + } return; } // Compatibility path for legacy listeners outside a hydrated WRN parent. diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index d91b3fb7..73e51ed8 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -63,7 +63,7 @@ Theme-aware, responsive toggle count component. Accessible password field with optional show and hide controls. - Mount: `data-component="TogglePassword"` -- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Enter your password"`, `autocomplete: string = "current-password"`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"`, `fields: unknown[] = []`, `visible: boolean = false`, `toggleable: boolean = true`, `toggleMode: string = "button"`, `checkboxLabel: string = "Show password"`, `showLabel: string = "Show password"`, `hideLabel: string = "Hide password"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `helpText: string = ""`, `validationMessage: string = ""`, `class: string = ""` +- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Enter your password"`, `autocomplete: string = "current-password"`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"`, `fields: unknown[] = []`, `visible: boolean = false`, `toggleable: boolean = true`, `toggleMode: string = "button"`, `checkboxLabel: string = "Show password"`, `showLabel: string = "Show password"`, `hideLabel: string = "Hide password"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `helpText: string = ""`, `validationMessage: string = ""`, `cornerHint: string = ""`, `cornerHref: string = ""`, `class: string = ""` - Slots: None - Outputs: `input({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `change({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `toggle({ visible: boolean })` @@ -835,7 +835,7 @@ Theme-aware, responsive navbar component. Theme-aware, responsive pagination component. - Mount: `data-component="Pagination"` -- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `class: string = ""` +- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `hrefTemplate: string = ""`, `class: string = ""` - Slots: `default` - Outputs: `change({ page: number; pageSize: number })`, `previous({ page: number })`, `next({ page: number })` diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index 9fa6cdf1..1dbaad30 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -9203,6 +9203,13 @@ "default": "\"Next\"", "options": [] }, + { + "name": "hrefTemplate", + "type": "string", + "required": false, + "default": "\"\"", + "options": [] + }, { "name": "class", "type": "string", @@ -13262,6 +13269,20 @@ "default": "\"\"", "options": [] }, + { + "name": "cornerHint", + "type": "string", + "required": false, + "default": "\"\"", + "options": [] + }, + { + "name": "cornerHref", + "type": "string", + "required": false, + "default": "\"\"", + "options": [] + }, { "name": "class", "type": "string", diff --git a/packages/ui/components/Pagination.wrn b/packages/ui/components/Pagination.wrn index dd4b80fd..3bff948c 100644 --- a/packages/ui/components/Pagination.wrn +++ b/packages/ui/components/Pagination.wrn @@ -26,6 +26,12 @@ component Pagination { label: string = "Pagination" previousLabel: string = "Previous" nextLabel: string = "Next" + // Render links instead of buttons. `{page}` is replaced with the target + // page number, e.g. hrefTemplate="/media/news?page={page}". Server-rendered + // pages should prefer this: the controls then work before hydration and + // without JavaScript, and each page is a real, crawlable URL. Buttons plus + // the change output remain the default for client-owned lists. + hrefTemplate: string = "" class: string = "" } @@ -46,6 +52,11 @@ component Pagination { return Math.min(value, lastPage()) } + shared function pageHref(target) { + var clamped = Math.min(Math.max(1, Number(target) || 1), lastPage()) + return String(hrefTemplate).split("{page}").join(String(clamped)) + } + shared function firstShown() { if (Number(total) < 1) { return 0 @@ -116,30 +127,55 @@ component Pagination {

- + {#if hrefTemplate} + + + {previousLabel} + + {:else} + + {/if}
    {#each pageNumbers() as entry}
  1. {entry.label} - + {#if hrefTemplate} + + {entry.label} + + {:else} + + {/if}
  2. {/each}
@@ -148,15 +184,28 @@ component Pagination { {currentPage()} / {lastPage()}

- + {#if hrefTemplate} + + {nextLabel} + + + {:else} + + {/if}
@@ -240,6 +289,7 @@ component Pagination { font: inherit; font-size: 0.85rem; cursor: pointer; + text-decoration: none; } .wrn-pagination__page:hover, @@ -247,6 +297,16 @@ component Pagination { background: var(--wrn-color-surface-soft); } + /* The href variant renders anchors, which cannot be disabled the way a + button can. At the first or last page the control is marked disabled and + made inert so it neither responds nor takes focus. */ + .wrn-pagination__page[data-disabled="true"], + .wrn-pagination__step[data-disabled="true"] { + opacity: 0.5; + pointer-events: none; + cursor: default; + } + .wrn-pagination__page:focus-visible, .wrn-pagination__step:focus-visible { outline: 2px solid var(--pagination-accent); diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 238afa04..74a10a2b 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -2558,6 +2558,64 @@ test("pagination clamps an out-of-range page instead of rendering nothing", asyn expect(current!.textContent).toContain("3"); }); +test("pagination renders links when given an href template", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const html = await renderComponent(source, { + page: 2, + pageSize: 10, + total: 50, + variant: "numbered", + hrefTemplate: "/news?page={page}", + }); + const dom = mountHtml(html); + + // Server-rendered navigation must be anchors, so the controls work before + // hydration and without JavaScript. + expect(dom.querySelector(".wrn-pagination__step button")).toBeNull(); + const steps = [...dom.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[]; + expect(steps.length).toBe(2); + expect(steps[0]!.getAttribute("href")).toBe("/news?page=1"); + expect(steps[1]!.getAttribute("href")).toBe("/news?page=3"); + + const pages = [...dom.querySelectorAll("a.wrn-pagination__page")] as HTMLElement[]; + expect(pages.some((page) => page.getAttribute("href") === "/news?page=5")).toBe(true); +}); + +test("pagination href steps are disabled and clamped at both ends", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const first = mountHtml( + await renderComponent(source, { + page: 1, + pageSize: 10, + total: 30, + hrefTemplate: "/news?page={page}", + }), + ); + const previous = first.querySelector("a.wrn-pagination__step") as HTMLElement; + expect(previous.getAttribute("data-disabled")).toBe("true"); + // Clamped rather than linking to page 0. + expect(previous.getAttribute("href")).toBe("/news?page=1"); + + const last = mountHtml( + await renderComponent(source, { + page: 3, + pageSize: 10, + total: 30, + hrefTemplate: "/news?page={page}", + }), + ); + const steps = [...last.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[]; + expect(steps[1]!.getAttribute("data-disabled")).toBe("true"); + expect(steps[1]!.getAttribute("href")).toBe("/news?page=3"); +}); + +test("pagination still renders buttons without an href template", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const dom = mountHtml(await renderComponent(source, { page: 2, pageSize: 10, total: 50 })); + expect(dom.querySelectorAll("button.wrn-pagination__step").length).toBe(2); + expect(dom.querySelector("a.wrn-pagination__step")).toBeNull(); +}); + test("pagination survives an empty dataset", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 });