You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A registered custom-element tag name written inside an HTML comment in an html template
is instantiated and rendered at SSR, and the surrounding markup is corrupted in the process.
Writing a component's tag in a comment is the natural way to document a template, so this
is easy to hit and hard to diagnose: the page renders, no error is thrown, and the damage
shows up as mangled HTML somewhere after the comment.
Found while documenting a shared element in website/app/layout.ts for #1127. The comment
read <!-- Shared aria-describedby target for every <copy-cmd> on the page. ... --> and the
layout silently grew a fully rendered copy button. The workaround was to drop the angle
brackets from the comment.
Minimal repro, run against the current tree:
import{html,WebComponent}from'@webjsdev/core';import{renderToString}from'@webjsdev/core/server';classMarkerextendsWebComponent{render(){returnhtml`<spanclass="INSTANTIATED">rendered</span>`;}}Marker.register('x-marker');awaitrenderToString(html`<div><!-- see <x-marker> for details --></div>`);awaitrenderToString(html`<div><!-- <x-marker></x-marker> --></div>`);awaitrenderToString(html`<div><!-- <x-unknown> --></div>`);
Actual output:
INSTANTIATED | comment containing the tag
"<div><!-- see <x-marker data-wj-host><!--webjs-hydrate--><span class=\"INSTANTIATED\">rendered</span></x-marker>"
INSTANTIATED | comment wrapping a full tag
"<div><!-- <x-marker data-wj-host><!--webjs-hydrate--><span class=\"INSTANTIATED\">rendered</span></x-marker> --></div>"
inert | comment with unregistered tag
"<div><!-- <x-unknown> --></div>"
Two distinct damage modes, both worse than a wasted render:
Tag mentioned in prose inside a comment. Everything after the tag name, INCLUDING the
closing --> and the </div>, is dropped. The output ends with an unterminated <!--,
so a browser parses the entire rest of the document as comment text. In the repro the
trailing for details --></div> is simply gone.
A full element inside a comment. The component renders inside the comment, and its
own <!--webjs-hydrate--> marker terminates the OUTER comment early (HTML comments do not
nest, the first --> closes). The component's markup then leaks out as visible content
followed by a stray --> in the text.
Note the third case: an UNREGISTERED tag inside a comment is inert. So whether a comment is
safe depends on whether its tag name happens to be a registered component, which is why this
reads as random rather than as a rule.
Design / approach
The custom-element walk needs to skip comment regions, the way the template scanner already
does for holes.
injectDSD (packages/core/src/render-server.js L426) builds one flat regex over the
assembled HTML (L444) and instantiates every match:
Nothing in that loop knows about <!-- / -->. The template scanner DOES track comment
state (L151 to L184, with a duplicate for the streaming path at L1375 to L1410), but it uses
it only to decide how to escape interpolated holes, and injectDSD runs afterward over the
already-assembled string.
So the fix is to make the walk comment-aware: compute the comment ranges once and skip any
match whose index falls inside one, or tokenize instead of regex-matching. Whichever way, it
must hold for the streaming scanner too, not just the buffered one.
The browser side is expected to differ, since the client parses templates with the real HTML
parser (via <template>), which never instantiates an element inside a comment. That makes
this an SSR-versus-client divergence and therefore a hydration mismatch, not only a rendering
bug. Worth confirming as part of the fix rather than assuming.
Implementation notes (for the implementing agent)
Where to edit
packages/core/src/render-server.js L426 injectDSD(), specifically the pattern regex
at L444 and the for (const m of html.matchAll(pattern)) loop at L450. This is the
instantiation site.
The same file's streaming scanner around L1375 to L1410 mirrors the buffered comment-state
machine at L151 to L184. Check whether the streaming render path reaches the same walk and
needs the same guard; a fix in one that skips the other leaves the bug live under <webjs-suspense> / streamed SSR.
processSuspenseElements() and substituteSlotsInRender() run over the same assembled
markup and are worth auditing for the identical blind spot.
Contrast with L877, where the slot-partition code already special-cases rest.startsWith('<!--').
That is the precedent for handling comments correctly in this file.
Landmines
The framework emits its OWN comments and depends on them: <!--webjs-hydrate--> (L585,
L604, L676) and the keyed client-router boundary pairs <!--wj:children:<segment>:<key>-->
from Rebuild the client-router swap on route-keyed comment boundaries #1015. injectDSD RECURSES on rendered output that already contains these, so a
comment-skipping pass must treat them as self-contained comments and must not start
skipping at a marker and swallow real elements after it. Getting this wrong breaks the
client router's boundary scan, which is strict: a mispaired or duplicated boundary poisons
the scan and degrades navigation to a full page load.
Do NOT fix this by stripping comments from SSR output. Author comments are legitimate
output, and the framework's own markers must survive.
The (?=[\s>/]) lookahead and longest-tag-first sort at L438 to L443 exist to stop my-card matching <my-card-2>. Preserve that behaviour when restructuring the match.
Invariants to respect
Invariant 9 (no backticks inside html template bodies) already constrains what can go in
a comment; this issue is about angle brackets, which are NOT currently constrained.
SSR output must stay byte-identical to what it produces today for every template that does
not contain a comment, so the elision differential and the ETag stability work in dogfood: webjs.dev pages are never cached at any layer #1127 are
not disturbed.
Tests + docs surfaces
Unit: packages/core/test/** alongside the existing SSR / DSD tests. Cover all three repro
cases (tag in prose, full element, unregistered tag) plus a comment that legitimately
contains a framework marker.
Counterfactual: the test must fail when the comment guard is reverted.
Browser: assert the client render of the same template leaves the comment inert, which is
what pins the SSR-versus-client parity claim.
Bun parity: the TS stripper and SSR dispatch are runtime-sensitive surfaces, so add or
update a test/bun/*.mjs assertion if the change touches the shared render path.
Docs: references/components.md and references/muscle-memory-gotchas.md are the natural
homes for "a component tag inside a template comment is still instantiated", if the
decision is to document any residual constraint rather than fully fix it.
Acceptance criteria
A registered tag inside an HTML comment is NOT instantiated at SSR
The comment and all markup after it survive unchanged (no dropped -->, no dropped
trailing siblings)
A component rendered adjacent to a comment still gets its normal DSD / hydration marker
The framework's own <!--webjs-hydrate--> and <!--wj:children:...--> markers still
round-trip, with the nested-layout partial-swap tests green
The streaming SSR path behaves identically to the buffered one
Client-side render of the same template is confirmed to match the SSR result
A counterfactual proves the new test fails when the guard is removed
Problem
A registered custom-element tag name written inside an HTML comment in an
htmltemplateis instantiated and rendered at SSR, and the surrounding markup is corrupted in the process.
Writing a component's tag in a comment is the natural way to document a template, so this
is easy to hit and hard to diagnose: the page renders, no error is thrown, and the damage
shows up as mangled HTML somewhere after the comment.
Found while documenting a shared element in
website/app/layout.tsfor #1127. The commentread
<!-- Shared aria-describedby target for every <copy-cmd> on the page. ... -->and thelayout silently grew a fully rendered copy button. The workaround was to drop the angle
brackets from the comment.
Minimal repro, run against the current tree:
Actual output:
Two distinct damage modes, both worse than a wasted render:
closing
-->and the</div>, is dropped. The output ends with an unterminated<!--,so a browser parses the entire rest of the document as comment text. In the repro the
trailing
for details --></div>is simply gone.own
<!--webjs-hydrate-->marker terminates the OUTER comment early (HTML comments do notnest, the first
-->closes). The component's markup then leaks out as visible contentfollowed by a stray
-->in the text.Note the third case: an UNREGISTERED tag inside a comment is inert. So whether a comment is
safe depends on whether its tag name happens to be a registered component, which is why this
reads as random rather than as a rule.
Design / approach
The custom-element walk needs to skip comment regions, the way the template scanner already
does for holes.
injectDSD(packages/core/src/render-server.jsL426) builds one flat regex over theassembled HTML (L444) and instantiates every match:
Nothing in that loop knows about
<!--/-->. The template scanner DOES track commentstate (L151 to L184, with a duplicate for the streaming path at L1375 to L1410), but it uses
it only to decide how to escape interpolated holes, and
injectDSDruns afterward over thealready-assembled string.
So the fix is to make the walk comment-aware: compute the comment ranges once and skip any
match whose index falls inside one, or tokenize instead of regex-matching. Whichever way, it
must hold for the streaming scanner too, not just the buffered one.
The browser side is expected to differ, since the client parses templates with the real HTML
parser (via
<template>), which never instantiates an element inside a comment. That makesthis an SSR-versus-client divergence and therefore a hydration mismatch, not only a rendering
bug. Worth confirming as part of the fix rather than assuming.
Implementation notes (for the implementing agent)
Where to edit
packages/core/src/render-server.jsL426injectDSD(), specifically thepatternregexat L444 and the
for (const m of html.matchAll(pattern))loop at L450. This is theinstantiation site.
machine at L151 to L184. Check whether the streaming render path reaches the same walk and
needs the same guard; a fix in one that skips the other leaves the bug live under
<webjs-suspense>/ streamed SSR.processSuspenseElements()andsubstituteSlotsInRender()run over the same assembledmarkup and are worth auditing for the identical blind spot.
rest.startsWith('<!--').That is the precedent for handling comments correctly in this file.
Landmines
<!--webjs-hydrate-->(L585,L604, L676) and the keyed client-router boundary pairs
<!--wj:children:<segment>:<key>-->from Rebuild the client-router swap on route-keyed comment boundaries #1015.
injectDSDRECURSES on rendered output that already contains these, so acomment-skipping pass must treat them as self-contained comments and must not start
skipping at a marker and swallow real elements after it. Getting this wrong breaks the
client router's boundary scan, which is strict: a mispaired or duplicated boundary poisons
the scan and degrades navigation to a full page load.
traversed needs the nested-layout partial-swap tests run, not just the SSR unit tests.
output, and the framework's own markers must survive.
(?=[\s>/])lookahead and longest-tag-first sort at L438 to L443 exist to stopmy-cardmatching<my-card-2>. Preserve that behaviour when restructuring the match.Invariants to respect
htmltemplate bodies) already constrains what can go ina comment; this issue is about angle brackets, which are NOT currently constrained.
not contain a comment, so the elision differential and the ETag stability work in dogfood: webjs.dev pages are never cached at any layer #1127 are
not disturbed.
Tests + docs surfaces
packages/core/test/**alongside the existing SSR / DSD tests. Cover all three reprocases (tag in prose, full element, unregistered tag) plus a comment that legitimately
contains a framework marker.
what pins the SSR-versus-client parity claim.
update a
test/bun/*.mjsassertion if the change touches the shared render path.references/components.mdandreferences/muscle-memory-gotchas.mdare the naturalhomes for "a component tag inside a template comment is still instantiated", if the
decision is to document any residual constraint rather than fully fix it.
Acceptance criteria
-->, no droppedtrailing siblings)
<!--webjs-hydrate-->and<!--wj:children:...-->markers stillround-trip, with the nested-layout partial-swap tests green