docs: give the real reason a Suspense fallback is read inline at SSR - #1347
Conversation
Two doc surfaces explained the inline `.fallback` read by saying a TemplateResult is not serializer-safe. That is true but secondary, and it points at a fixable-looking obstacle: a reader could conclude that making TemplateResult serializable would unlock the `data-webjs-prop-*` path, and spend real time on it. The load-bearing constraint is timing. `_hydratePropAttrs()` runs from `connectedCallback`, behind an `if (!isBrowser) return` guard, so the property channel applies at hydration by construction. A placeholder whose whole job is to be in the first flushed bytes cannot wait for that, however serializable it is. Lead with timing on both surfaces, matching the ordering the source comment at packages/core/src/render-server.js:403 already uses. The core module table keeps serializer-safety as the trailing secondary fact, since it is real and worth knowing when reading `renderTemplate`.
|
Design rationale: why timing leads and serializer-safety trails I went back and forth on whether to keep both reasons on both surfaces, and settled on ordering by what a reader can act on. Serializer-safety reads as a property of the payload, so it invites the reader to change the payload. Timing is a property of the pipeline, and no change to the fallback value moves a hydration-time write earlier than a server flush. That asymmetry is the whole reason the old wording was worth correcting: it was true, but it pointed at a fixable-looking obstacle, and someone could have spent real time trying to make On the docs page I dropped the serializer clause entirely rather than demoting it. That page is a reader-facing sentence about using the boundary, not about the renderer's internals, and the serializer half is a fact about a channel the reader is being told never to use, so it adds a clause without adding a decision. On I also checked how the neighbours solve this before committing to the shape. React Fizz makes the fallback the boundary segment's rendered children on the server and replaces the segment later ( I left |
The first pass replaced one wrong reason with another. Checking the source
rather than the claim:
`html.js:14` returns a plain `{ _$webjs: 'template', strings, values }`
object, and it round-trips through `stringify` / `parse` intact, so "a
TemplateResult is not serializer-safe" was never true. And the
`data-webjs-prop-*` channel is not browser-only either: `consumePropAttrs`
at `render-server.js:959` decodes it server-side and assigns onto the
instance before `render()`.
What actually forces the design is narrower. `webjs-suspense.js:38` defines
the element only when `customElements` exists, so the injectDSD walk skips
it (`lookup(tag)` finds no class) and no server-side instance ever runs
`consumePropAttrs`. For this one element the only remaining consumer is
`connectedCallback`, which is far too late for a placeholder whose whole job
is to be in the first flushed bytes.
Say that on all three surfaces, including the source comment the two doc
copies had drifted from, since it carried the serializer claim that started
this. Also removes the contradiction with `docs/ssr/page.ts:161`, which
correctly says the walker consumes the channel before `render()`.
vivek7405
left a comment
There was a problem hiding this comment.
Went through this against the actual source and the first pass was wrong, so I have reworked it.
The premise I inherited from the issue does not survive a read of the code. html.js:14 returns a plain { _$webjs: 'template', strings, values } object and it round-trips through stringify / parse intact, so "a TemplateResult is not serializer-safe" was never true to begin with. That is the claim the issue set out to demote as secondary, and it should have been deleted as false. My replacement was wrong in the other direction: consumePropAttrs at render-server.js:959 decodes data-webjs-prop-* on the server and assigns it before render(), so the channel is not browser-only and "applies at hydration" is false as a general statement about it.
The real constraint is narrower than either. webjs-suspense.js:38 defines the element only when customElements exists, so the injectDSD walk skips it at render-server.js:934 (lookup(tag) finds no class) and no server-side instance runs consumePropAttrs. For this one element the only consumer left is connectedCallback, too late for the first flushed bytes. All three surfaces now say that.
I also pulled render-server.js:403 into scope, which the issue had ruled out. It is where the serializer claim originated and both doc copies drifted from it, so fixing the copies and leaving the original would guarantee the next reader hits the wrong one. Comment-only, no behaviour change.
|
Why the issue's prescribed wording was not followed The issue was written as a settled plan with exact replacement text, and I departed from it on both surfaces plus one file it ruled out of scope. Recording why, since the diff alone will not show it. The plan's whole argument was that serializer-safety is a true but secondary fact and timing is the load-bearing one. The first half is wrong. A The second half was directionally right and stated too broadly. "Applies at hydration" is not true of the Both errors have the same shape as the one being fixed: a claim about the code that reads plausibly and was never run. So I checked these executably before writing them down rather than reasoning from the surrounding prose, which is what produced the original drift. That is also why |
Invariant 11 bans a space-surrounded semicolon as pause punctuation. Also unnests the parenthetical, which had a second one inside it.
Closes #1324
Summary
Two doc surfaces explained why a
<webjs-suspense>.fallbackis read inline at SSR by saying aTemplateResultis not serializer-safe. That reason is wrong, and so was the first reason I replaced it with. Checking the source rather than the claim:TemplateResultis serializer-safe.packages/core/src/html.js:14returns a plain{ _$webjs: 'template', strings, values }object, and it round-trips throughstringify/parseintact. So the original justification was not merely secondary, it was false.data-webjs-prop-*channel is not browser-only.consumePropAttrsatpackages/core/src/render-server.js:959decodes it on the server and assigns onto the instance at:971, beforerender()is called.What actually forces the design is narrower than either claim.
packages/core/src/webjs-suspense.js:38defines the element only whencustomElementsexists, so theinjectDSDwalk skips it atpackages/core/src/render-server.js:934(lookup(tag)finds no class) and no server-side instance ever runsconsumePropAttrs. For this one element the only consumer left isconnectedCallback, which is far too late for a placeholder whose whole job is to be in the first flushed bytes. MeanwhilerenderTemplaterenders the fallback to HTML during byte emission and carries it asdata-webjs-fallback(render-server.js:403), whichprocessSuspenseElementswrites straight into the shell (:1310).This also removes a contradiction inside the docs site.
website/app/docs/ssr/page.ts:161already says the custom-element.propround-trip is consumed by the SSR walker beforerender(), which both the old wording and my first pass talked past.Changed
website/app/docs/suspense/page.ts:94, the docs-site Suspense page.packages/core/AGENTS.md:38, thewebjs-suspense.jsrow of the core module table.packages/core/src/render-server.js:403, the canonical source comment. The issue ruled this out of scope on the reasoning that it "already leads with the full picture". It does not: it is where the serializer claim originated and both doc copies drifted from it, so correcting the copies and leaving the original would guarantee the next reader hits the wrong one. Comment-only, no behaviour change.Test plan
cd website && npm test: 455 server tests pass, 84 browser tests pass, exit 0. That includeswebsite/test/ssr/docs-links.test.ts("every internal /docs link the docs publish resolves"), which boots a real request handler over thewebsiteapp and fetches every internal/docs/...link, so it rendered/docs/suspense(200) and resolved the<a href="/docs/loading-states">(200) inside the edited paragraph.cd website && npx webjs check: all checks pass.stringifyon aTemplateResultreturns{"_$webjs":"template","strings":[...],"values":[...]}andparserevives it with the samestrings/values, andconsumePropAttrs/lookup(tag)were read in place.No new test. The change alters explanatory prose and one comment, and no code path, export, rendered structure, or attribute. The only test that could exist would assert a paragraph against a substring of its own new wording, which pins prose to itself and has no precedent in the repo.
Browser, e2e, smoke, and Bun parity are N/A: no runtime-sensitive surface moves and the one
packages/*/srcedit is a comment. The two-app dogfood check is N/A for the same reason, and itswebsitehalf is subsumed by the 455-test SSR suite above, which boots the app throughcreateRequestHandlerand renders the edited page.Note
The issue body prescribed exact replacement wording and ruled
render-server.jsout of scope. I did not follow either, because the prescribed wording asserts something the source contradicts. Everything turned up here is fixed in this PR; nothing is filed as follow-up work.