From 599857c60b80b07be82f6877e7d641cdfe5631ff Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 27 Jul 2026 16:36:15 +0530 Subject: [PATCH 1/6] fix(website): make pages cacheable in the browser and stabilize the ETag Every page carried `max-age=0`, so a stored copy was never reusable and each view paid a full round trip even when nothing had changed. Assets cached fine, which is why the site looked healthy. Raise the browser copy to 60s: small enough that nobody holds pre-deploy HTML for long, large enough to serve back/forward, repeat visits, and a second tab from disk. That alone would not have helped much, because the revalidation it replaces could never succeed. `copy-cmd` minted its aria-describedby id from a module-scope counter that never resets in a long-lived server, so two renders of the same page emitted different bytes, hashed to different ETags, and no `If-None-Match` could ever match. Every revalidation shipped the whole document instead of an empty 304. The description sentence is identical for every instance, so the hint element moves to the root layout and all instances reference the one id. Nothing per-instance is generated, which is what makes the output stable. The new SSR test asserts determinism generically rather than checking for this one counter, since any timestamp, random value, or incrementing id in any component would reintroduce the same silent failure. --- website/app/layout.ts | 21 ++++++-- website/components/copy-cmd.ts | 14 ++--- .../test/components/browser/copy-cmd.test.js | 52 +++++++++++++++--- website/test/ssr/render-determinism.test.ts | 54 +++++++++++++++++++ 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 website/test/ssr/render-determinism.test.ts diff --git a/website/app/layout.ts b/website/app/layout.ts index 3e23620dd..e6dd15ecb 100644 --- a/website/app/layout.ts +++ b/website/app/layout.ts @@ -46,9 +46,13 @@ export function generateMetadata(ctx: { url: string }) { // The marketing site is identical for every visitor (no per-user / session // reads), so it is safe to cache at the CDN. Set on the root layout so it // applies to every page (a per-user page could override with no-store). - // `s-maxage` is the edge cache; `max-age=0` keeps the browser revalidating; - // `stale-while-revalidate` serves instantly while refreshing. - cacheControl: 'public, max-age=0, s-maxage=600, stale-while-revalidate=86400', + // `s-maxage` is the edge cache; `stale-while-revalidate` serves instantly + // while refreshing. `max-age=60` is the browser copy: the previous `0` meant + // a page was NEVER reusable from disk, so every view paid a round trip even + // when nothing had changed. 60s is the smallest value that yields real + // browser cache hits (back/forward, repeat visits, a second tab) while + // bounding how long a reader can hold pre-deploy HTML to one minute. + cacheControl: 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400', title: TITLE, description: DESCRIPTION, openGraph: { @@ -284,6 +288,17 @@ export default function RootLayout({ children }: { children: unknown }) { Skip to content + + Copy command to clipboard +
diff --git a/website/components/copy-cmd.ts b/website/components/copy-cmd.ts index 3de7a3e14..299e53698 100644 --- a/website/components/copy-cmd.ts +++ b/website/components/copy-cmd.ts @@ -22,7 +22,13 @@ import { WebComponent, html, signal } from '@webjsdev/core'; * addEventListener in lifecycle hooks. Cleanup of the auto-reset * timer happens in disconnectedCallback. */ -let HINT_SEQ = 0; +// The aria-describedby target, rendered ONCE by the root layout. Every +// copy-cmd shares it because they all carry the same description sentence. +// It is deliberately NOT a per-instance generated id: the only way to mint one +// at SSR is a module-scope counter, which never resets in a long-lived server, +// so consecutive renders of the same page emit different bytes and the ETag can +// never match an If-None-Match. That silently disabled 304s site-wide (#1127). +const HINT_ID = 'copy-cmd-hint'; export class CopyCmd extends WebComponent { copied = signal(false); @@ -32,9 +38,6 @@ export class CopyCmd extends WebComponent { // "Copied" even though `copied` is already true. private _copies = signal(0); private _resetTimer: number | undefined; - // Per-instance id so aria-describedby points at this button's own hint - // (multiple copy-cmd can share a page; the value is document-unique). - private _hintId = `copy-cmd-hint-${HINT_SEQ++}`; disconnectedCallback() { if (this._resetTimer) clearTimeout(this._resetTimer); @@ -90,7 +93,7 @@ export class CopyCmd extends WebComponent { data-copy-text role="button" tabindex="0" - aria-describedby=${this._hintId} + aria-describedby=${HINT_ID} @click=${this._copy} @keydown=${this._onKey} > @@ -101,7 +104,6 @@ export class CopyCmd extends WebComponent { tabindex="-1" @click=${this._copy} >${isCopied ? CHECK_ICON : COPY_ICON} - Copy command to clipboard ${announce} `; diff --git a/website/test/components/browser/copy-cmd.test.js b/website/test/components/browser/copy-cmd.test.js index 7f47d345a..60cc7c695 100644 --- a/website/test/components/browser/copy-cmd.test.js +++ b/website/test/components/browser/copy-cmd.test.js @@ -56,8 +56,25 @@ suite('copy-cmd', () => { return el; }; - setup(() => { stubClipboard(); stubGtag(); }); - teardown(() => { restoreClipboard && restoreClipboard(); restoreGtag && restoreGtag(); }); + // The root layout renders the shared aria-describedby target once per page + // (the component deliberately does not mint a per-instance id, see #1127). + // These tests mount the component in isolation, so stand the hint up here the + // same way the layout does, otherwise the reference has nothing to resolve to. + let hintEl; + const addSharedHint = () => { + hintEl = document.createElement('span'); + hintEl.id = 'copy-cmd-hint'; + hintEl.className = 'sr-only'; + hintEl.textContent = 'Copy command to clipboard'; + document.body.appendChild(hintEl); + }; + + setup(() => { stubClipboard(); stubGtag(); addSharedHint(); }); + teardown(() => { + restoreClipboard && restoreClipboard(); + restoreGtag && restoreGtag(); + hintEl && hintEl.remove(); + }); test('renders the slotted command and a copy affordance', async () => { const el = await mount('npm create webjs@latest my-app'); @@ -96,21 +113,44 @@ suite('copy-cmd', () => { // ...and an sr-only describedby hint adds the copy ACTION as the description. const hintId = target.getAttribute('aria-describedby'); assert.ok(hintId, 'the button references a description via aria-describedby'); - const hint = el.querySelector('#' + hintId); - assert.ok(hint, 'the referenced hint element exists in the same subtree'); + const hint = document.getElementById(hintId); + assert.ok(hint, 'the referenced hint element resolves in the document'); assert.ok(/copy/i.test(hint.textContent) && /clipboard/i.test(hint.textContent), 'the hint describes the copy-to-clipboard action'); assert.ok(hint.className.includes('sr-only'), 'the hint is visually hidden (screen-reader only)'); document.body.removeChild(el); }); - test('two copy-cmd on a page get distinct describedby hint ids', async () => { + test('two copy-cmd on a page share one hint id that resolves to a single element', async () => { const a = await mount('npm create webjs@latest one'); const b = await mount('npm create webjs@latest two'); const idA = a.querySelector('[data-copy-text]').getAttribute('aria-describedby'); const idB = b.querySelector('[data-copy-text]').getAttribute('aria-describedby'); assert.ok(idA && idB, 'both buttons carry a describedby id'); - assert.ok(idA !== idB, 'the two hint ids are unique so neither shadows the other'); + // Both point at the layout's single hint. The description sentence is the + // same for every instance, so sharing it is correct AND keeps the id out of + // the rendered HTML, which is what makes the page byte-stable across + // renders (a per-instance counter did not reset per render, #1127). + assert.equal(idA, idB, 'both instances reference the same shared hint'); + assert.equal( + document.querySelectorAll('#' + idA).length, 1, + 'exactly one element carries the hint id (no duplicate ids in the document)', + ); + assert.ok(/copy/i.test(document.getElementById(idA).textContent), + 'the shared hint describes the copy action'); + a.remove(); b.remove(); + }); + + test('renders no generated ids, so repeated renders are byte-identical', async () => { + // The regression guard for #1127. Any per-render-varying value in the + // component's output (a counter, a timestamp, a random id) changes the + // page's bytes between renders, which changes its ETag, which means an + // If-None-Match can never match and a 304 is impossible. Assert the + // rendered markup of two independent instances of the SAME command is + // identical, which no monotonic id scheme can satisfy. + const a = await mount('npm create webjs@latest my-app'); + const b = await mount('npm create webjs@latest my-app'); + assert.equal(a.innerHTML, b.innerHTML, 'two renders of the same command emit identical markup'); a.remove(); b.remove(); }); diff --git a/website/test/ssr/render-determinism.test.ts b/website/test/ssr/render-determinism.test.ts new file mode 100644 index 000000000..93f233011 --- /dev/null +++ b/website/test/ssr/render-determinism.test.ts @@ -0,0 +1,54 @@ +/** + * Render determinism, the precondition for conditional GET (#1127). + * + * The site opts every page into a public `Cache-Control` via the root layout, + * which also makes the framework attach a weak ETag and answer `If-None-Match` + * with a 304. That whole path is silently dead if a page does not render the + * same bytes twice: a different body hashes to a different ETag, the validator + * the browser holds never matches, and every revalidation ships the full + * document instead of an empty 304. + * + * Nothing else in the suite catches this. The page still renders, every + * assertion about its content still passes, and the only symptom is a caching + * layer that quietly never engages. The original offender was a module-scope + * counter minting `copy-cmd-hint-` ids that never reset in a long-lived + * server, so consecutive renders of the home page differed by a handful of + * digits. + * + * This test is deliberately generic rather than a check for that one counter, + * because the failure mode is a CLASS: any `Date.now()`, `Math.random()`, + * incrementing id, or iteration-order wobble in any component a page renders + * reintroduces it. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { renderToString } from '@webjsdev/core/server'; +import LandingPage from '#app/page.ts'; +import WhatIsWebJs from '#app/what-is-webjs/page.ts'; +import WhyWebJs from '#app/why-webjs/page.ts'; + +const PAGES: Array<[string, () => unknown]> = [ + ['/', () => LandingPage()], + ['/what-is-webjs', () => WhatIsWebJs()], + ['/why-webjs', () => WhyWebJs()], +]; + +for (const [route, render] of PAGES) { + test(`${route} renders identical bytes twice (ETag stability)`, async () => { + const first = await renderToString(render() as any); + const second = await renderToString(render() as any); + assert.ok(first.length > 1000, 'renders substantial HTML'); + if (first !== second) { + // Surface the first divergence rather than dumping two large documents, + // so the failure names the offending markup directly. + const a = first.split('\n'); + const b = second.split('\n'); + const i = a.findIndex((line, n) => line !== b[n]); + assert.fail( + `${route} rendered different bytes on a second render, so its ETag changes every request ` + + `and a 304 is impossible. First divergence at line ${i + 1}:\n` + + ` render 1: ${a[i]?.trim()}\n render 2: ${b[i]?.trim()}`, + ); + } + }); +} From 023eaae463cee5e6ddebbc1d1b93653a5854bcc2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 27 Jul 2026 16:39:05 +0530 Subject: [PATCH 2/6] docs: note render determinism as a precondition for conditional GET The ETag / 304 path silently never engages when a page renders different bytes each time, and nothing errors, so the failure is invisible unless you know to look for it. Also flag that max-age=0 means the browser never reuses a stored copy, which is the part that surprised me on our own site. --- website/app/docs/cache/page.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/app/docs/cache/page.ts b/website/app/docs/cache/page.ts index f3bcc5297..9e7e70f6b 100644 --- a/website/app/docs/cache/page.ts +++ b/website/app/docs/cache/page.ts @@ -94,6 +94,10 @@ export const metadata = {

This sets the standard Cache-Control header on the HTTP response. Browsers and CDNs cache the rendered page without any server-side state.

+

A public cacheControl also opts the page into conditional GET: WebJs attaches a weak ETag and answers a matching If-None-Match with a 304, so a revalidation costs a few hundred bytes instead of the whole document. That only works if the page renders the same bytes twice. Any per-render-varying value in a component (a Date.now(), a Math.random(), or an incrementing id from a module-scope counter, which never resets in a long-lived server) changes the body, changes the ETag, and means no If-None-Match can ever match. Nothing errors when this happens: the page renders correctly and the caching layer just silently never engages, so it is worth a test that renders a page twice and asserts the output is identical.

+ +

Note that max-age=0 is a common default worth thinking about. It keeps the browser revalidating on every view, which is right when deploys must be visible immediately, but it means a stored copy is never reused directly. A small non-zero value is what produces real browser cache hits on back/forward and repeat visits.

+

Server HTML Response Cache (export const revalidate)

For a page that renders identical HTML for every visitor, opt into the server HTML response cache so the SSR pipeline runs once per window instead of once per request (webjs's no-build equivalent of Next.js's Full Route Cache and ISR). Declare a revalidation window on the page module:

From 9c5ff22706d4a76773a92f7885dee14a030f1774 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 27 Jul 2026 16:48:33 +0530 Subject: [PATCH 3/6] fix(website): keep the copy-cmd description inside the component My first pass moved the aria-describedby target into the root layout so every instance could share one id. That was wrong. A component's own accessible description is part of the component, and putting it in the layout means copy-cmd only works in pages that render through THAT layout: an error boundary, global-error, another app, or a scaffold template would each ship a dangling aria-describedby pointing at nothing, which is worse for a screen reader than having no description at all. The description now sits inside the click target as visually-hidden text, so it joins the accessible name after the command. That needs no id, so the render stays byte-stable (the point of #1127) and the component is self-contained again. Because the hidden text shares the click target, _copy reads the slot's assigned nodes instead of the target's textContent, so the clipboard still gets only the command. Verified in a browser: the accessible name reads "npm create webjs@latest my-app Copy command to clipboard", the hidden span is position:absolute, and scrollWidth still equals clientWidth, so it adds no scroll to the overflow-x container. --- website/app/layout.ts | 11 --- website/components/copy-cmd.ts | 41 +++++++----- .../test/components/browser/copy-cmd.test.js | 67 +++++++------------ 3 files changed, 48 insertions(+), 71 deletions(-) diff --git a/website/app/layout.ts b/website/app/layout.ts index e6dd15ecb..736009bc4 100644 --- a/website/app/layout.ts +++ b/website/app/layout.ts @@ -288,17 +288,6 @@ export default function RootLayout({ children }: { children: unknown }) { Skip to content - - Copy command to clipboard -
diff --git a/website/components/copy-cmd.ts b/website/components/copy-cmd.ts index 299e53698..33cb58b45 100644 --- a/website/components/copy-cmd.ts +++ b/website/components/copy-cmd.ts @@ -5,10 +5,22 @@ import { WebComponent, html, signal } from '@webjsdev/core'; * affordance. Light DOM, Tailwind utilities throughout. The whole * inner wrapper is the click target (text or icon both trigger copy); * the icon is an always-visible visual hint, not a separate focusable - * element. The command text is the button's accessible NAME, and an - * sr-only aria-describedby hint adds "Copy command to clipboard" as its - * description, so a screen reader announces both the payload and the - * action without the label hiding the command. + * element. The command text and a visually-hidden "Copy command to + * clipboard" both sit inside the click target, so the accessible NAME is + * the command followed by the action: a screen reader announces the + * payload and what will happen to it, and no aria-label hides the command. + * + * The description is inline rather than an aria-describedby reference on + * purpose. A reference needs a document-unique id, the only way to mint one + * during SSR is a module-scope counter, and a counter never resets in a + * long-lived server, so consecutive renders of the same page emit different + * bytes. That changes the page's ETag on every request and silently kills + * the 304 path site-wide (#1127). Inline text needs no id, so the output is + * byte-stable and the component stays self-contained. + * + * Because the hidden text lives inside the click target, _copy reads the + * SLOT's assigned nodes rather than the target's textContent, so only the + * command reaches the clipboard. * * Usage: * npm create webjs@latest my-app @@ -22,14 +34,6 @@ import { WebComponent, html, signal } from '@webjsdev/core'; * addEventListener in lifecycle hooks. Cleanup of the auto-reset * timer happens in disconnectedCallback. */ -// The aria-describedby target, rendered ONCE by the root layout. Every -// copy-cmd shares it because they all carry the same description sentence. -// It is deliberately NOT a per-instance generated id: the only way to mint one -// at SSR is a module-scope counter, which never resets in a long-lived server, -// so consecutive renders of the same page emit different bytes and the ETag can -// never match an If-None-Match. That silently disabled 304s site-wide (#1127). -const HINT_ID = 'copy-cmd-hint'; - export class CopyCmd extends WebComponent { copied = signal(false); // Increments on every successful copy. The live-region text is keyed off its @@ -45,8 +49,14 @@ export class CopyCmd extends WebComponent { } _copy = async () => { - const textEl = this.querySelector('[data-copy-text]'); - const text = (textEl?.textContent || '').trim(); + // Read the SLOTTED command specifically, not the click target's whole + // textContent: the target also carries the visually-hidden description + // that names the action for a screen reader, and that must never land on + // the clipboard. assignedNodes is the native light-DOM slot read. + const slot = this.querySelector('slot'); + const text = (slot + ? slot.assignedNodes({ flatten: true }).map((n) => n.textContent || '').join('') + : '').trim(); if (!text) return; try { await navigator.clipboard.writeText(text); @@ -93,10 +103,9 @@ export class CopyCmd extends WebComponent { data-copy-text role="button" tabindex="0" - aria-describedby=${HINT_ID} @click=${this._copy} @keydown=${this._onKey} - > + > Copy command to clipboard