diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index b15a5c97c..209adc979 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -78,7 +78,7 @@ document.addEventListener('webjs:navigation-fallback', (e) => { ## Link Prefetch -Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff. +Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). With a stable page ETag the revalidation is answered with a cheap 304, so the cost is a conditional round-trip, not a re-download. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff. Override per link with the `data-prefetch` attribute. diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index a97226e30..f7b4d5e6e 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -120,6 +120,12 @@ class Panel extends WebComponent({ label: String }) { The full `` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration). +**A tag name inside an HTML comment is not instantiated.** `` documents the template and renders no component, the same as in a browser. That holds for component tags, ``, and ``. It extends to attribute values and to every element whose content the HTML parser reads as text rather than markup: `` is TEXT (it only steps back to the escaped state) and the + * element ends at the NEXT ``. The legacy comment-wrapped inline + * script that document.writes a script tag is the pattern that produces this. + * Stopping at the first `` there re-opened the original #1128 bug in + * the one element the scanner most explicitly claims to handle. + * + * @param {string} html + * @param {number} from index just past the opening tag's `>` + * @returns {number} + */ +function endOfScriptContent(html, from) { + const re = /|<\/script(?=[\s/>])|])/gi; + re.lastIndex = from; + let escaped = false; + let dbl = false; + let m; + while ((m = re.exec(html)) !== null) { + const t = m[0]; + if (t === '`, ``, and any dash + // run followed by `>` clear BOTH flags: entering fresh it cancels the + // escape before it starts, and inside an escaped or double-escaped body + // it is the exit a browser honours, after which the element ends at the + // next ``. + let q = m.index + 4; + while (html[q] === '-') q += 1; + if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; } + else if (!escaped) escaped = true; + } + else if (t === '-->') { escaped = false; dbl = false; } + else if (t[1] === '/') { + if (dbl) dbl = false; + else return m.index; + } else if (escaped) dbl = true; + } + return -1; +} + +/** + * Byte ranges of `html` where a tag-shaped match is NOT an element (#1128). + * + * The element scanners below match tags with a flat regex over already- + * assembled markup, which has no notion of an HTML context. So a registered tag + * name written inside a comment used to be constructed and rendered as a real + * element, and the replacement consumed the rest of the comment INCLUDING its + * closing `-->`, leaving an unterminated comment that swallowed every following + * byte. Whether it happened depended on whether the name in the comment was a + * registered component, which is what made it look random. + * + * This is a single left-to-right pass rather than a search for `` and `` close + * immediately, `--!>` closes as well as `-->`, and an unterminated comment + * runs to EOF, exactly as a browser would treat the same bytes. + * - **Markup declarations and bogus comments** (``, ``), + * which end at the next `>`. + * - **Tags**, consumed with their quoted attribute values, so `<` and `', lt + 4); - if (end === -1) { + // Find the comment's end the same way inertRanges does, rather than with + // a bare `indexOf('-->')`. The two helpers both decide where a comment + // stops, so a bare search makes them DISAGREE on the spec short forms + // (`--!>`, ``, ``): this one would run past the real end and + // swallow the slotted children that follow, silently routing a + // `slot="head"` child into the default slot. + const commentEnd = endOfComment(html, lt); + if (commentEnd === -1) { defaultBuf += rest; cursor = html.length; break; } - defaultBuf += html.slice(lt, end + 3); - cursor = end + 3; + defaultBuf += html.slice(lt, commentEnd); + cursor = commentEnd; continue; } if (rest.startsWith('])*?)(\/?)>/gi; + // A `` written inside a comment is documentation, not a slot (#1128). + // Substituting one is worse here than in the element walk: a commented slot + // has no ``, so the fallback scan below swallows the rest of the + // template, the component's REAL slot is never substituted, and the authored + // children are dropped from the page entirely. + const inert = inertRanges(rendered); + const isInert = inertAt(inert); let m; while ((m = slotRe.exec(rendered)) !== null) { + if (isInert(m.index)) continue; result += rendered.slice(cursor, m.index); const [fullOpen, attrsRaw, selfCloseSlash] = m; const isSelfClose = !!selfCloseSlash; @@ -984,7 +1340,7 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) { totalEnd = m.index + fullOpen.length; } else { const innerStart = m.index + fullOpen.length; - const closeIdx = findClosingTagInString(rendered, innerStart, 'slot'); + const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert); if (closeIdx === -1) { fallback = rendered.slice(innerStart); totalEnd = rendered.length; @@ -1028,6 +1384,41 @@ function isRawtextTag(tag) { return tag === 'script' || tag === 'style'; } +/** + * RCDATA elements: their content is text (character references aside), so a + * tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so + * the two lists stay together rather than drifting apart. + * @param {string} tag + * @returns {boolean} + */ +function isRcdataTag(tag) { + return tag === 'textarea' || tag === 'title'; +} + +/** + * Elements whose content the HTML tokenizer never reads as markup, for the + * purposes of `inertRanges` only (#1128). + * + * Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is + * shared with the template tokenizer, where widening it would change how holes + * inside those elements are escaped. This one answers a narrower question, + * "can a tag-shaped string in here be a real element", and the answer is no for + * every raw-text and RCDATA element, not just the two the template path cares + * about. `after` + ); + assert.ok(!out.includes('RENDERED'), 'a component tag inside an iframe does not render'); + assert.ok(out.includes('after'), 'markup after the iframe survives'); + const after = await renderToString( + html`
` + ); + assert.ok(after.includes('RENDERED'), 'scanning resumes after the iframe closes'); +}); + +test('a component inside noscript still renders', async () => { + // The exclusion that matters most, and the one a future widening of the + // text-only list would silently break. A browser with scripting disabled + // parses noscript content as markup, and that reader is exactly who + // server-rendered output exists for, so components inside it must render. + const out = await renderToString( + html`
` + ); + assert.ok(out.includes('RENDERED'), 'noscript content is markup, not text'); +}); + +test('a component inside a template still renders', async () => { + // The other exclusion: template content is parsed, and Declarative Shadow DOM + // and the streamed swap templates both depend on components inside it. + const out = await renderToString( + html`
` + ); + assert.ok(out.includes('RENDERED'), 'template content is parsed as markup'); +}); + +test('the other text-only elements are text too', async () => { + // iframe is covered separately as the realistic case; these pin the rest of + // the set so a future trim of the predicate reds a test instead of silently + // reintroducing the document-destroying path. + for (const tag of ['xmp', 'noembed', 'noframes']) { + const out = await renderToString( + html`
${unsafeHTML(`<${tag}>see here`)}after
` + ); + assert.ok(!out.includes('RENDERED'), `a component tag inside <${tag}> does not render`); + assert.ok(out.includes('after'), `markup after <${tag}> survives`); + const after = await renderToString( + html`
${unsafeHTML(`<${tag}>text`)}
` + ); + assert.ok(after.includes('RENDERED'), `scanning resumes after <${tag}> closes`); + } +}); + +test('plaintext runs to the end of the document', async () => { + // plaintext has no end tag at all: everything after it is text, so nothing + // following can be a component. + const out = await renderToString( + html`
see <comment-probe> here</div>` + ); + assert.ok(!out.includes('RENDERED'), 'nothing after plaintext is instantiated'); +}); + +test('the <!---> short form closes like a browser closes it', async () => { + // comment-start-dash + `>`. Distinct from `<!-->` and separately unpinned: + // without its branch the comment never ends and the component vanishes. + const out = await renderToString(html`<div><!---><comment-probe></comment-probe></div>`); + assert.ok(out.includes('RENDERED'), '<!---> is a complete empty comment'); +}); + +test('a doubled = does not open a quoted value', async () => { + // Per spec the character after `=` is reconsumed in attribute-value-unquoted + // state, so in `title==">` the quote is an ordinary value character and the + // tag ends at the first `>`. Treating it as a delimiter leaves an odd quote + // count, runs the scan to EOF, and silently disables the fix for the rest of + // the page. Reachable through unsafeHTML or third-party markup. + const out = await renderToString( + html`<div>${unsafeHTML('<a title==">go</a>')}<comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'the component after the doubled = still renders'); +}); + +test('a missing attribute value does not swallow the tag end', async () => { + // `<a href=>` is a missing-value parse error and the `>` still ends the tag. + // Consuming it as a value character runs the scan to the NEXT `>`, which eats + // the real tag end: the component after it stops rendering, and a following + // <style> never arms its skip, so a tag inside the style is instantiated. + const live = await renderToString( + html`<div>${unsafeHTML('<a href=></a>')}<comment-probe></comment-probe></div>` + ); + assert.ok(live.includes('RENDERED'), 'the component after a valueless attribute renders'); + + const styled = await renderToString( + html`<div>${unsafeHTML('<a href=></a><style>/* <comment-probe> */</style><span>after</span>')}</div>` + ); + assert.ok(!styled.includes('RENDERED'), 'the following style still skips its content'); + assert.ok(styled.includes('<span>after</span>'), 'markup after the style survives'); +}); + +test('a slash ending an unquoted value is not a self-closing solidus', async () => { + // Per spec `/` is an ordinary character inside an unquoted value, so + // `<iframe src=/embed/x/>` is NOT self-closing. Reading it as one suppresses + // the text-only skip and instantiates whatever is inside, which is the + // destructive direction. `src=https://host/embed/` is a realistic literal. + const out = await renderToString( + html`<div>${unsafeHTML('<iframe src=/embed/x/>see <comment-probe> here</iframe><span>after</span>')}</div>` + ); + assert.ok(!out.includes('RENDERED'), 'the iframe still holds text, not markup'); + assert.ok(out.includes('<span>after</span>'), 'markup after the iframe survives'); +}); + +test('whitespace between = and a quoted value still opens the value', async () => { + // `title= "..."` is ordinary. If the space is not skipped while waiting for + // the value, the quote never opens, the tag ends at the first `>` INSIDE the + // value, and scanning resumes in the middle of an attribute. + const out = await renderToString( + html`<div>${unsafeHTML('<div title= "a > b <comment-probe> c"></div>')}<span>after</span></div>` + ); + assert.ok(!out.includes('RENDERED'), 'the component named inside the value stays inert'); + assert.ok(out.includes('<span>after</span>'), 'markup after it survives'); +}); + +test('a self-closed text-only tag does not swallow the document', async () => { + // In SVG and MathML foreign content a self-closing tag genuinely closes, so + // `<svg><title/></svg>` has no `</title` to find. Running the range to EOF + // there makes every component in the rest of the document inert. + const out = await renderToString( + html`<div>${unsafeHTML('<svg><title/></svg>')}<comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'a component after a self-closed title still renders'); +}); + +test('a close tag is matched on a tag boundary, not a prefix', async () => { + // `</styleguide>` is not `</style>`. Without the lookahead the style content + // ends early and the component inside it is instantiated. + const out = await renderToString( + html`<div>${unsafeHTML('<style>/* see </styleguide> */ <comment-probe></comment-probe></style>')}<span>after</span></div>` + ); + assert.ok(!out.includes('RENDERED'), 'a prefix close tag does not end the style'); + assert.ok(out.includes('<span>after</span>'), 'markup after the style survives'); +}); + +test('a literal </plaintext> does not resume scanning', async () => { + // plaintext has no end tag: a browser reads `</plaintext>` as more text. + const out = await renderToString( + html`<div>${unsafeHTML('<plaintext>x</plaintext><comment-probe></comment-probe>')}</div>` + ); + assert.ok(!out.includes('RENDERED'), 'nothing after plaintext is instantiated, close tag or not'); +}); + +test('a markup declaration does not expose the markup after it', async () => { + // `<![CDATA[ ... ]]>` and `<!x ...>` are bogus comments that run to the next + // `>`. Without that branch the bytes inside are scanned as markup. + const cdata = await renderToString( + html`<div><![CDATA[ <comment-probe> ]]><span>after</span></div>` + ); + assert.ok(!cdata.includes('RENDERED'), 'a tag inside CDATA does not render'); + assert.ok(cdata.includes('<span>after</span>'), 'markup after it survives'); +}); + +test('a comment AFTER a real suspense boundary is still mapped correctly', async () => { + // Pins the `consumed` offset arithmetic. The suspense scanner computes its + // ranges against the full input but walks a shrinking string, so a boundary + // consumed earlier has to advance the offset or every later comment is + // mis-mapped and its contents run. A commented boundary with no real one + // before it only exercises the offset-zero path. + const out = await renderToString(html`<div><webjs-suspense>a</webjs-suspense><!-- <webjs-suspense><comment-probe></comment-probe></webjs-suspense> --><span>after</span></div>`); + assert.ok(!out.includes('RENDERED'), 'the commented boundary after a real one stays inert'); + assert.ok(out.includes('<span>after</span>'), 'markup after it survives'); +}); + +test('a commented-out boundary consumes nothing from the streaming context', async () => { + // The blocking path is what plain renderToString exercises, so the serious + // half went untested: under streaming a commented boundary would consume an + // id, queue a pending chunk, and emit a swap script targeting an element that + // exists only inside a comment, so it could never resolve and the fallback + // would sit there forever. Assert the context is untouched. + const ctx = { pending: [], nextId: 1, usedComponents: new Set() }; + const out = await renderToString( + html`<div><!-- <webjs-suspense data-webjs-fallback="x"><comment-probe></comment-probe></webjs-suspense> --><span>after</span></div>`, + { ssr: true, suspenseCtx: ctx }, + ); + assert.equal(ctx.nextId, 1, 'no boundary id was consumed'); + assert.equal(ctx.pending.length, 0, 'nothing was queued to stream'); + assert.equal(ctx.usedComponents.size, 0, 'no component module was marked used'); + assert.ok(!out.includes('data-webjs-resolve'), 'no swap template was emitted'); + assert.ok(out.includes('<span>after</span>'), 'markup after the comment survives'); +}); + +test('a short-form comment does not swallow the slotted children after it', async () => { + // Pins the partition scanner sharing endOfComment. With a bare indexOf('-->') + // it runs past a `<!-->` or `--!>` and eats the children, so a slot="head" + // child is silently routed into the default slot. + const out = await renderToString( + // `--!>` is the discriminating form: `<!-->` happens to contain `-->`, so a + // bare indexOf finds the same end and the test could not fail. + html`<slot-shell><!-- note --!><b slot="head">HEAD</b><i>BODY</i></slot-shell>` + ); + assert.ok(out.includes('HEAD'), 'the named-slot child is still projected'); + assert.ok(!out.includes('NO-HEAD'), 'the named slot did not fall back'); +}); + +// #1133: element boundaries. The depth ledger in findClosingTagInString must +// not count a tag inside a comment for EITHER side, or the element's end is +// mis-detected and the surrounding markup shuffles. + +test('a commented close tag is not the element end', async () => { + const out = await renderToString(html`<slot-shell>kid<!-- </slot-shell> --></slot-shell><span>ok</span>`); + // The whole comment stays inside the projected children, the real close tag + // still closes, and the trailing span stays OUTSIDE the component. + assert.ok(out.includes('kid<!-- </slot-shell> -->'), 'the comment rides the children intact'); + assert.ok(/<\/slot-shell><span>ok<\/span>/.test(out), 'the span lands after the real close'); +}); + +test('a commented open tag does not inflate the nesting depth', async () => { + const out = await renderToString(html`<slot-shell>kid<!-- <slot-shell> --></slot-shell><span>ok</span>`); + assert.ok(/<\/slot-shell><span>ok<\/span>/.test(out), 'depth returns to zero at the real close'); +}); + +test('a commented slot close tag does not truncate the fallback', async () => { + const out = await renderToString(html`<slot-in-comment><b>kid</b></slot-in-comment>`); + assert.ok(out.includes('<b>kid</b>'), 'children still project'); + // And a fallback that documents its own close tag in a comment: + const out2 = await renderToString(html`<slot-shell></slot-shell>`); + assert.ok(out2.includes('NO-HEAD'), 'the named fallback renders when nothing is slotted'); +}); + +// #1134: script data is not plain raw text. `<!--` + `<script` enters the +// double-escaped state, where `</script>` is TEXT and the element ends at the +// NEXT one. The legacy comment-wrapped inline script produces exactly this. + +test('a double-escaped script body stays text to its real end', async () => { + const out = await renderToString(html`<div><script type="text/plain"><!-- <script> </script> <comment-probe></comment-probe> --></script><span>after</span></div>`); + assert.ok(!out.includes('RENDERED'), 'the tag inside the double-escaped body is text'); + assert.ok(out.includes('<span>after</span>'), 'markup after the script survives'); +}); + +test('an ordinary script still ends at its first close tag', async () => { + const out = await renderToString( + html`<div><script>var a = 1;</script><comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'scanning resumes at the first </script> when not escaped'); + // And an escaped-but-not-double-escaped body (just a <!--, no inner <script) + // also ends at its first close tag, which is what a browser does. + const esc = await renderToString( + html`<div><script>var a = "<!--";</script><comment-probe></comment-probe></div>` + ); + assert.ok(esc.includes('RENDERED'), 'a lone <!-- in a script does not defer the close'); +}); + +test('a comment naming a boundary and a text-only tag does not derail a real boundary after it', async () => { + // The suspense scanner skips a commented boundary and re-slices its input + // just past the match, which can land MID-comment. The close-tag search for + // the next REAL boundary must therefore use ranges computed on the full + // input, not a re-tokenization of the suffix: restarted mid-comment, a + // text-only opener named later in that comment (here <textarea>) read as a + // real unclosed element, everything to EOF went inert, and the boundary + // swallowed its own close tag plus the trailing markup. + const out = await renderToString(html`<div><!-- a <webjs-suspense> demo uses <textarea> input --><webjs-suspense><comment-probe></comment-probe></webjs-suspense><span>after</span></div>`); + assert.ok(out.includes('RENDERED'), 'the real boundary renders its children'); + // The discriminators, chosen so a swallowed close tag cannot fake a pass: + // the failure emits inner + a SYNTHESIZED close, so the tag count doubles + // and the document ends with the synthesized `</webjs-suspense>` instead of + // the real `</div>`. + assert.equal((out.match(/<\/webjs-suspense>/g) || []).length, 1, + 'exactly one boundary close tag (no synthesized duplicate)'); + assert.ok(out.endsWith('</div>'), 'the trailing markup stays outside the boundary'); +}); + +test('the <!--> short form inside a script cancels the escape', async () => { + // The dash-dash state entered by <!-- exits straight back to plain script + // data on >, so a lone "<!-->" in a script does NOT arm the double-escape: + // even with a "<script>" string later in the body, the element ends at its + // first </script>, which is where a browser ends it. + const out = await renderToString( + html`<div>${unsafeHTML('<script>var x = "<!-->"; var y = "<script>";</script>')}<comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'the component after the script still renders'); +}); + +test('a <!--> exit inside an already-escaped script clears both escape flags', async () => { + // The token's trailing dashes reach the dash-dash state from EVERY script + // state, and dash-dash exits to plain data on `>`. Missing the exit when + // already escaped meant a later "<script" string armed the double-escape + // and the element end moved past its real close, silently unrendering every + // component after the script. A browser renders this probe. + const out = await renderToString( + html`<div>${unsafeHTML('<script>a<!--b<!--><script></script>')}<comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'the component after the script still renders'); +}); + +test('the dash-dash exit also steps down from the double-escaped state', async () => { + // Reaches the exit with the double-escape armed, which the case above never + // does: with only the escaped flag cleared, the stale double-escape flag + // makes the real </script> read as a step-down instead of the end, the + // element runs to EOF, and the component after it silently vanishes. + const out = await renderToString( + html`<div>${unsafeHTML('<script>a<!--<script>b<!--></script>')}<comment-probe></comment-probe></div>` + ); + assert.ok(out.includes('RENDERED'), 'the component after the script still renders'); +}); + +test('the client router boundary comments do not hide the components between them', async () => { + // The load-bearing case (#1015, #1114). SSR wraps each layout's children in + // KEYED boundary comment PAIRS, and the router's scan is strict: a mispaired + // or duplicated boundary degrades navigation to a full page load. Those + // comments must be skipped as comments WITHOUT swallowing the real markup + // between them, so assert a component inside a pair still renders and both + // markers survive verbatim. + const out = await renderToString(html`<div> + <!--wj:children:root:/--> + <comment-probe></comment-probe> + <!--/wj:children:root--> + <comment-probe></comment-probe> + </div>`); + assert.equal(out.match(/RENDERED/g)?.length, 2, 'components inside and after the boundary pair both render'); + assert.ok(out.includes('<!--wj:children:root:/-->'), 'the opening boundary survives verbatim'); + assert.ok(out.includes('<!--/wj:children:root-->'), 'the closing boundary survives verbatim'); +}); diff --git a/packages/core/test/routing/browser/fetch-revalidates.test.js b/packages/core/test/routing/browser/fetch-revalidates.test.js new file mode 100644 index 000000000..d79b18e05 --- /dev/null +++ b/packages/core/test/routing/browser/fetch-revalidates.test.js @@ -0,0 +1,101 @@ +/** + * Both router fetches must revalidate rather than trust the HTTP cache (#1131). + * + * The deploy check reads `x-webjs-build` / `x-webjs-src` off these responses. + * On a page served with a browser `max-age`, a default-cache fetch can be + * satisfied wholly from the HTTP cache, replaying pre-deploy ids; the check + * then compares two equally stale values and skips the snapshot eviction, so + * a deploy stays invisible for the freshness window plus one + * stale-while-revalidate serving per URL. `cache: 'no-cache'` forces a + * conditional request (with stable page ETags, a cheap 304), which keeps the + * ids live. + * + * Runs in a real browser because the assertion is on the exact RequestInit the + * router hands to fetch, captured through the real click and prefetch paths. + */ +import { enableClientRouter, disableClientRouter } from '../../../src/router-client.js'; + +import { assert } from '../../../../../test/browser-assert.js'; +const tick = () => new Promise((r) => setTimeout(r, 0)); +async function settle() { for (let i = 0; i < 4; i++) await tick(); } + +suite('Client router: fetches revalidate instead of trusting the HTTP cache (#1131)', () => { + let container, origFetch, calls; + + function setup() { + enableClientRouter(); + container = document.createElement('div'); + container.innerHTML = + '<header id="outer-chrome">CHROME</header>' + + '<!--wj:children:/:/-->' + + '<a id="nav-link" href="/somewhere">go</a>' + + '<span id="slot-content">ORIGINAL</span>' + + '<!--/wj:children:/-->'; + document.body.appendChild(container); + origFetch = window.fetch; + calls = []; + window.fetch = (url, init) => { + calls.push({ url: String(url), init: init || {} }); + // A JSON body drives the router into its in-place error recovery, so the + // test page never full-navigates away under the stub. + return Promise.resolve(new Response(JSON.stringify({ nope: true }), { + status: 500, + headers: { 'content-type': 'application/json', 'x-webjs-build': '' }, + })); + }; + } + function teardown() { + window.fetch = origFetch; + container.remove(); + disableClientRouter(); + } + + test('a navigation fetch is sent with cache: no-cache', async () => { + setup(); + try { + document.getElementById('nav-link').click(); + await settle(); + const nav = calls.find((c) => c.url.includes('/somewhere')); + assert.ok(nav, 'the click produced a router fetch'); + assert.equal(nav.init.cache, 'no-cache', + 'the navigation fetch must revalidate so the deploy check sees live build ids'); + } finally { + teardown(); + } + }); + + test('a prefetch fetch is sent with cache: no-cache', async () => { + const origIO = window.IntersectionObserver; + const ioInstances = []; + window.IntersectionObserver = class { + constructor(cb) { this.cb = cb; ioInstances.push(this); } + observe() {} + unobserve() {} + disconnect() {} + emit(el) { this.cb([{ target: el, isIntersecting: true }], this); } + }; + disableClientRouter(); + enableClientRouter(); + setup(); + try { + const link = document.createElement('a'); + link.href = '/prefetched-page'; + link.setAttribute('data-prefetch', 'viewport'); + container.appendChild(link); + // Surface the link to the router's (stubbed) viewport observer and sit + // out the dwell gate. + document.dispatchEvent(new Event('DOMContentLoaded')); + await settle(); + for (const io of ioInstances) io.emit(link); + await new Promise((r) => setTimeout(r, 400)); + await settle(); + const pf = calls.find((c) => c.url.includes('/prefetched-page')); + assert.ok(pf, 'the viewport dwell produced a prefetch fetch'); + assert.equal(pf.init.cache, 'no-cache', + 'the prefetch fetch must revalidate so the deploy check sees live build ids'); + } finally { + window.IntersectionObserver = origIO; + teardown(); + } + }); +}); diff --git a/test/bun/comment-not-an-element.mjs b/test/bun/comment-not-an-element.mjs new file mode 100644 index 000000000..5033b5f31 --- /dev/null +++ b/test/bun/comment-not-an-element.mjs @@ -0,0 +1,103 @@ +/** + * Cross-runtime proof that SSR treats HTML contexts identically under + * WHICHEVER runtime executes this file (#1128). Run it under both: + * + * node test/bun/comment-not-an-element.mjs + * bun test/bun/comment-not-an-element.mjs + * + * A registered tag name inside a comment used to be constructed and rendered as + * a real element, and the replacement ate the comment's closing `-->` along + * with the markup after it. The fix decides that by tokenizing the assembled + * HTML: comments (including the `--!>` and `<!-->` short forms), markup + * declarations, tags with their quoted attribute values, raw text, and RCDATA. + * + * This is runtime-sensitive for a specific reason, not by category. The scanner + * leans on `String.prototype.indexOf` / `startsWith` offsets, a `RegExp` with a + * lookahead built per call, and `matchAll` index arithmetic that has to line up + * exactly with those offsets. Any divergence in regex semantics or index + * handling between V8 and JSC would not throw, it would silently shift a range + * boundary, and the symptom is a component that renders on one runtime and + * silently vanishes on the other. So both the positive and negative cases are + * asserted here rather than just "it does not crash". + * + * A plain assert script (not `*.test.mjs`, so the node:test runner does not + * double-run it); it exits non-zero on failure. Run from the repo root so the + * bare `@webjsdev/core` specifier resolves to the workspace package. + */ +import assert from 'node:assert/strict'; +import { html, WebComponent } from '@webjsdev/core'; +import { renderToString } from '@webjsdev/core/server'; + +const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; + +class BunProbe extends WebComponent { + render() { return html`<b>PROBE</b>`; } +} +BunProbe.register('bun-probe'); + +// Starts with "script": a raw-text check not anchored on the tag boundary +// misreads this as a <script> and stops scanning for the rest of the document. +class ScriptShaped extends WebComponent { + render() { return html`<b>SCRIPTSHAPED</b>`; } +} +ScriptShaped.register('script-shaped'); + +const rendered = async (tpl) => (await renderToString(tpl)).includes('<b>PROBE</b>'); + +// Inert: the tag is text, not markup. +assert.equal(await rendered(html`<div><!-- <bun-probe> --></div>`), false, + 'a component inside a comment must not render'); +assert.equal(await rendered(html`<div><!-- <bun-probe> </div>`), false, + 'an unterminated comment runs to EOF'); +assert.equal(await rendered(html`<div><style>/* <bun-probe> */</style></div>`), false, + 'raw-text content is text'); +assert.equal(await rendered(html`<div><script>var a = "<bun-probe>";</script></div>`), false, + 'script content is text'); + +// Live: everything else still renders. These are the cases where a subtly +// wrong range boundary makes a component disappear. +assert.equal(await rendered(html`<div><bun-probe></bun-probe></div>`), true, + 'a plain component still renders'); +assert.equal(await rendered(html`<div><!-- note --><bun-probe></bun-probe></div>`), true, + 'a component after a closed comment still renders'); +assert.equal(await rendered(html`<div><a title="use <!-- here"></a><bun-probe></bun-probe></div>`), true, + 'an attribute value containing "<!--" does not open a comment'); +assert.equal(await rendered(html`<div><!-- x --!><bun-probe></bun-probe></div>`), true, + 'the abrupt-closing form --!> closes the comment'); +assert.equal(await rendered(html`<div><!--><bun-probe></bun-probe></div>`), true, + '<!--> is a complete empty comment'); +assert.equal(await rendered(html`<div><textarea><!-- hi</textarea><bun-probe></bun-probe></div>`), true, + 'RCDATA content does not open a comment'); +assert.equal(await rendered(html`<div><script-shaped></script-shaped><bun-probe></bun-probe></div>`), true, + 'a component whose name starts with a raw-text tag name is not treated as one'); +// The END of each text-only skip, which is the boundary a range-arithmetic +// divergence would move. An inert range never deletes text, so only a real +// component AFTER the element proves scanning resumed on this runtime. +assert.equal(await rendered(html`<div><style>.a{color:red}</style><bun-probe></bun-probe></div>`), true, + 'scanning resumes after a style closes'); +assert.equal(await rendered(html`<div><script>var a=1;</script><bun-probe></bun-probe></div>`), true, + 'scanning resumes after a script closes'); +assert.equal(await rendered(html`<div><iframe>fallback</iframe><bun-probe></bun-probe></div>`), true, + 'scanning resumes after an iframe closes'); +assert.equal(await rendered(html`<div><noscript><bun-probe></bun-probe></noscript></div>`), true, + 'noscript content is markup, not text'); + +// Element boundaries (#1133): a commented tag counts for neither side of the +// nesting ledger, so the element still ends at its REAL close tag. +{ + const out = await renderToString(html`<div><!-- </div> --><bun-probe></bun-probe></div>`); + assert.ok(out.includes('<b>PROBE</b>'), 'a commented close tag does not end the element early'); +} +// Script data double-escape (#1134): <!-- + <script defers the close to the +// NEXT </script>, so the tag between them is text on both runtimes. +assert.equal(await rendered(html`<div><script type="text/plain"><!-- <script> </script> <bun-probe></bun-probe> --></script></div>`), false, + 'a double-escaped script body is text to its real end'); + +// Content preservation: the damage mode that made this worth fixing. +const out = await renderToString( + html`<div><!-- see <bun-probe> here --><span>after</span></div>` +); +assert.ok(out.includes('-->'), 'the comment is still closed'); +assert.ok(out.includes('<span>after</span>'), 'markup after the comment survives'); + +console.log(`[bun-parity] comment-not-an-element OK on ${runtime}`); diff --git a/test/bun/comment-not-an-element.test.mjs b/test/bun/comment-not-an-element.test.mjs new file mode 100644 index 000000000..65381f9c7 --- /dev/null +++ b/test/bun/comment-not-an-element.test.mjs @@ -0,0 +1,13 @@ +/** + * Run the cross-runtime HTML-context proof (#1128) under WHICHEVER runtime + * executes the suite. Picked up by the root `node --test` runner (so `npm test` + * exercises the Node path); CI also runs `bun test/bun/comment-not-an-element.mjs` + * for the Bun path. The proof is a plain assert script + * (`comment-not-an-element.mjs`, not `*.test.mjs`, so the runner does not + * double-run it); importing it runs it and throws on any failure. + */ +import { test } from 'node:test'; + +test('SSR treats comments, raw text, and RCDATA as text on this runtime (#1128)', async () => { + await import('./comment-not-an-element.mjs'); +}); diff --git a/website/app/layout.ts b/website/app/layout.ts index 2e6c15340..7294f2e47 100644 --- a/website/app/layout.ts +++ b/website/app/layout.ts @@ -52,13 +52,11 @@ export function generateMetadata(ctx: { url: string }) { // 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. - // Known tradeoff (#1131): the client router fetches with the default HTTP - // cache mode, so within that minute a prefetch or soft nav can be served - // wholly from cache, handing the router pre-deploy `x-webjs-build` / - // `x-webjs-src` headers. Its deploy check then compares two equally stale - // ids and skips the snapshot eviction. `max-age=0` avoided that by forcing - // a revalidation every time. Accepted here because the blast radius is one - // minute of slightly stale marketing copy, and a hard nav still corrects it. + // The client router revalidates its own fetches (#1131), so soft navs and + // prefetches always see live `x-webjs-build` ids and deploy detection is + // unaffected by this max-age; with stable page ETags the revalidation is a + // cheap 304. Document navigations (new tab, back/forward) still serve + // straight from disk within the window. cacheControl: 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400', title: TITLE, description: DESCRIPTION,