Skip to content

docs: give the real reason a Suspense fallback is read inline at SSR #1324

Description

@vivek7405

All line anchors in this issue were verified against HEAD e5806e24 ("docs: correct the CI route named in seven bun test wrappers (#1345)"). Re-check them with grep -n before editing if HEAD has moved.

Problem

Two doc surfaces give the wrong reason for why a <webjs-suspense> .fallback is read inline at SSR instead of riding the data-webjs-prop-* channel. Both name serializer-safety, which is a true but secondary fact, and neither names the constraint that actually forces the design.

Occurrence 1 (the one the issue was filed for). website/app/docs/suspense/page.ts:94, verified present at HEAD, reads verbatim:

    <p>The <code>.fallback</code> is read at SSR as the inline placeholder (never through the <code>data-webjs-prop-*</code> path, since a <code>TemplateResult</code> is not serializer-safe) and must be an unquoted property hole. <code>renderFallback()</code> on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see <a href="/docs/loading-states">Loading States</a>.</p>

Occurrence 2 (not named in the original filing, found by grepping the repo). packages/core/AGENTS.md:38, the webjs-suspense.js row of the module table, carries the same serializer-only parenthetical:

| `webjs-suspense.js` | The `<webjs-suspense>` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, since a TemplateResult is not serializer-safe) and, in a streaming context, flushes the fallback as `<webjs-suspense id="sN">` while pushing the children to `ctx.pending` for out-of-order streaming ...

Serializer-safety explains why a TemplateResult cannot ride that path. It does not explain why it must not. The load-bearing constraint is TIMING. The data-webjs-prop-* channel applies the property during hydration, which is far too late for a placeholder whose entire job is to be in the first flushed bytes.

The distinction matters because the current wording points at a fixable-looking obstacle. A reader could reasonably conclude that making TemplateResult serializable would unlock the data-webjs-prop-* path, and spend real time on it. It would not. Even a perfectly serializable fallback applied at hydration arrives after the shell has flushed, so there is no placeholder during the window the boundary exists for.

The timing claim, proven against the source

The correction asserts a fact about when each path runs. Both halves are verified in the shipped source, so the corrected sentence is grounded rather than asserted.

The .fallback is consumed during the SSR byte emission. packages/core/src/render-server.js:403 sits inside the kind === 'prop' branch of renderTemplate's attribute scanner and special-cases the boundary before the generic property path can run:

          // `<webjs-suspense .fallback=${html`...`}>` (#471). A TemplateResult
          // is not serializer-safe (the normal data-webjs-prop-* path would
          // drop it) and a normal custom-element prop applies only at
          // hydration, too late for the streaming placeholder. So render the
          // fallback to HTML now and carry it as data-webjs-fallback, which the
          // injectDSD streaming pre-pass reads as the boundary placeholder.
          if (currentTag === 'webjs-suspense' && name === 'fallback') {
            const fbHtml = await render(val, ctx);
            out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;

Note that this comment ALREADY gives both reasons, timing included, and gives the timing reason the weight it deserves. It is the canonical statement and needs no change. The two doc surfaces are the drifted copies.

processSuspenseElements (declared at packages/core/src/render-server.js:1243, called from injectDSD at packages/core/src/render-server.js:910) then reads that carrier at packages/core/src/render-server.js:1280 and writes the fallback markup straight into the shell at packages/core/src/render-server.js:1310:

    const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
    const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
...
      result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;

Those bytes are the shell. Nothing client-side is involved, which is why the docblock at packages/core/src/render-server.js:1234 can say first-load streaming needs no client runtime.

The data-webjs-prop-* channel applies at hydration. WebComponent's connectedCallback at packages/core/src/component.js:836 is the only caller of the decoder:

  connectedCallback() {
    if (!isBrowser) return;
...
    if (!this.__webjsPropsHydrated) {
      this.__webjsPropsHydrated = true;
      this._hydratePropAttrs();
    }

_hydratePropAttrs() at packages/core/src/component.js:904 scans data-webjs-prop-* attributes, decodes each through the wire serializer, assigns the property, and removes the attribute. The if (!isBrowser) return guard on the first line is the proof in one line. That path is browser-only by construction, so it cannot run before the server has flushed anything. The docs site already documents this timing independently at website/app/docs/ssr/page.ts:146 and website/app/docs/ssr/page.ts:161, both of which correctly say the attributes are applied and stripped on connectedCallback. So the suspense page contradicts the SSR page as the repo stands.

What is stale in the original filing

  • The original body scoped the work to one line in website/app/docs/suspense/page.ts. It missed packages/core/AGENTS.md:38, which repeats the same serializer-only explanation on the framework's own agent-facing surface. Both are in scope now.
  • The original body said "check whether any test asserts on this paragraph before editing". That check has been done. grep -rn "serializer-safe" test/ website/test/ returns nothing, and no file under test/docs/ or website/test/ssr/ references docs/suspense. There is nothing to update, so the implementer does not need to repeat the search.
  • The original body's provenance note (PR feat: resolve form-submitter boundness in webjs check and make the residual loud #1314 closed unmerged as superseded by PR feat: make a bound form submitter carry its own submission #1317) is accurate and is retained below.

Provenance. This correction was written as part of PR #1314, which was closed unmerged as superseded by PR #1317 (#1307). The line is unrelated to that PR's subject and would otherwise have been lost with it, which is the only reason this issue exists. Nothing else from #1314 is worth salvaging.

Design / approach

Settled decision. Lead the parenthetical with the timing reason on every surface that carries it. On the docs page, replace the serializer clause outright with the reviewed wording. On packages/core/AGENTS.md, lead with timing and keep serializer-safety as the trailing secondary fact, matching the ordering the source comment at packages/core/src/render-server.js:403 already uses.

Why this ordering is right. A reason a reader can act on belongs first. 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. Putting timing first makes the constraint unarguable at a glance, and keeping serializer-safety second on the source-facing surface preserves the fact that the channel would also drop the value, which is real and worth knowing when reading renderTemplate.

Prior art consulted.

  • React Fizz, ~/Documents/Projects/frameworks/react/packages/react-server/src/ReactFizzServer.js:1447. The comment reads "Each time we enter a suspense boundary, we split out into a new segment for the fallback so that we can later replace that segment with the content", and at line 1464, "The children of the boundary segment is actually the fallback." React makes the fallback the boundary segment's rendered children on the SERVER, emitted into the stream, and replaces the segment later. That is structurally what WebJs does with data-webjs-fallback. In neither framework is the fallback a value the client is handed after boot. It is markup the server already wrote. This is the industry-standard shape and it settles the ordering question.
  • lit-ssr, ~/Documents/Projects/frameworks/lit/packages/labs/ssr/src/lib/render-value.ts:1099. renderPropertyPart returns undefined for any property that is not a reflected IDL attribute, so lit-ssr emits NOTHING into the HTML for a rich property binding on a custom element. The data-webjs-prop-* channel is WebJs's deliberate divergence from that, and packages/core/src/component.js:836 is where it lands, on the client. Reading lit-ssr confirms there is no server-side prop channel to fall back to. The corrected sentence is therefore not describing a WebJs quirk, it is describing the only ordering the platform allows.

Alternatives considered and rejected.

  1. Keep both reasons on the docs page, timing first. Rejected. The docs-site paragraph is a reader-facing sentence inside a page about using the boundary, not about the renderer's internals. The serializer half is a fact about a channel the reader is being told never to use, so carrying it there adds a clause without adding a decision the reader can make. The reviewed wording in the issue already settled this and the parenthetical stays single-clause.
  2. Drop the parenthetical entirely and just say the property hole must be unquoted. Rejected. The parenthetical exists because a reader who knows about data-webjs-prop-* from website/app/docs/ssr/page.ts will ask why this one is different. Deleting the answer reopens the question the sentence was written to close.
  3. Also rewrite the packages/core/src/render-server.js:403 comment for symmetry. Rejected. That comment is already correct and already leads with the full picture. Editing it would stage a packages/*/src file, which arms the require-docs-with-src.sh and require-tests-with-src.sh gates over a change that alters no behaviour, for no gain.
  4. Leave packages/core/AGENTS.md for a follow-up. Rejected on the explicit instruction below, and on merit. The two sentences drifted from the same source comment, so fixing one and leaving the other guarantees the next reader hits the wrong one.

Implementation plan

No follow-up issues. The implementer files NO follow-up issue for anything this work turns up, and fixes every finding inside this same PR. If a finding is genuinely out of reach of a single PR, it is reported to the user as a note in the PR description, never filed as an issue.

Branch prefix docs/, per AGENTS.md. Cut a worktree first, per the unconditional worktree rule. Note that no node_modules install is needed for the steps below unless step 4 is run.

Step 1. Correct website/app/docs/suspense/page.ts:94.

Today:

    <p>The <code>.fallback</code> is read at SSR as the inline placeholder (never through the <code>data-webjs-prop-*</code> path, since a <code>TemplateResult</code> is not serializer-safe) and must be an unquoted property hole. <code>renderFallback()</code> on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see <a href="/docs/loading-states">Loading States</a>.</p>

After:

    <p>The <code>.fallback</code> is read at SSR as the inline placeholder (never through the <code>data-webjs-prop-*</code> path, because that applies the property at hydration, far too late for a placeholder that has to be in the first flushed bytes) and must be an unquoted property hole. <code>renderFallback()</code> on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see <a href="/docs/loading-states">Loading States</a>.</p>

Only the parenthetical changes. The second sentence, the renderFallback() clause and the Loading States link, is unchanged. The paragraph stays a single line with the same four-space indentation as its neighbours at lines 88 and 93. This is a .ts file inside an html template in the in-repo website app, so the <p> is template text and no escaping changes are needed.

Step 2. Correct the webjs-suspense.js row at packages/core/AGENTS.md:38.

The row is one long table cell. Change only the parenthetical inside it.

Today:

`injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, since a TemplateResult is not serializer-safe) and, in a streaming context, flushes the fallback as `<webjs-suspense id="sN">`

After:

`injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because the normal `data-webjs-prop-*` path applies at hydration, far too late for a placeholder that has to be in the first flushed bytes, and a `TemplateResult` would not survive that path anyway) and, in a streaming context, flushes the fallback as `<webjs-suspense id="sN">`

Everything else in the row, including the display:contents note, the swap-path sentence and the SSR-inert marker, is unchanged. Keep it on one line, since it is a markdown table row and a newline would break the table.

Step 3. Confirm no third occurrence was introduced. Run, from the repo root:

grep -rn "serializer-safe\|not serializable" --include=*.md --include=*.ts --include=*.js --include=*.mjs . \
  | grep -v node_modules | grep -v packages/core/dist \
  | grep -i "fallback\|data-webjs-prop\|suspense"

The only remaining hit must be packages/core/src/render-server.js:404, the canonical source comment, which stays as it is. Two occurrences exist at HEAD e5806e24 and both are fixed by steps 1 and 2. This grep is the guard against a third appearing between the filing of this issue and the branch.

Step 4 (optional verification, needs a linked worktree). cd website && npx webjs check. The website app is unaffected structurally by a prose edit, so this is a formality rather than a real risk, but it is listed in the acceptance criteria and is cheap once the worktree is linked with npm run worktree:link.

Surfaces deliberately NOT touched, each verified clean at HEAD.

  • packages/core/src/render-server.js:403. Already correct, already leads with the full picture. See rejected alternative 3.
  • AGENTS.md:227 and AGENTS.md:272. Both describe <webjs-suspense> and neither mentions the data-webjs-prop-* path or serializer-safety, so neither carries the defect.
  • .agents/skills/webjs/references/client-router-and-streaming.md:198, .agents/skills/webjs/references/components.md:276, .agents/skills/webjs/references/muscle-memory-gotchas.md:221. All three show the boundary and its fallback without explaining the channel.
  • packages/cli/templates/gallery/app/features/suspense/page.ts:26. Says only that .fallback is an unquoted property hole per invariant 4, which is true and complete for a gallery demo.
  • website/app/docs/data-fetching/page.ts:41, website/app/docs/components/page.ts:470, website/app/docs/lifecycle/page.ts:100, packages/mcp/src/mcp-docs.js:401, blog/component-suspense-streaming.md:38, blog/async-render-in-web-components.md:88, examples/blog/app/stream-demo/page.ts:30. All show or describe the boundary without the wrong justification.
  • website/app/docs/ssr/page.ts:146 and website/app/docs/ssr/page.ts:161. Already state the hydration timing of data-webjs-prop-* correctly. They are the pages this correction brings the suspense page into agreement with.
  • website/lib/ui/docs-shell.ts:288. Mentions data-webjs-prop-* for an unrelated reason (why the docs nav is slotted rather than passed as a property) and is correct.

Tests

No new test is written, and this is a deliberate decision rather than an omission.

Which layers do not apply and why:

  • Unit. There is nothing to assert. The change alters two explanatory sentences and no code path, no export, no rendered structure and no attribute. The only test that could exist would assert.match the paragraph against a substring of its own new wording, which pins prose to itself. That has no defect class behind it, breaks on any future rewording, and has no precedent in the repo. Everything under test/docs/ asserts structural or API facts instead (doc-source-consistency.test.mjs checks that imports shown in doc fences resolve against the real exported surface, llms.test.mjs, site-entity-graph.test.mjs, migration-page.test.mjs and the rest follow the same pattern), and nothing there asserts an explanatory sentence.
  • Browser. No component, no DOM behaviour, no hydration path changes. Nothing to observe in a browser that differs before and after.
  • e2e. No route, response or navigation changes.
  • Smoke. The scaffold is untouched. packages/cli/templates/gallery/app/features/suspense/page.ts was checked and carries no wrong explanation, so no scaffold surface moves.
  • Bun parity. No runtime-sensitive surface is touched. Nothing under packages/*/src is edited at all, so require-bun-parity-with-runtime-src.sh does not arm and no test/bun/** assertion is warranted.

Counterfactual, and why none exists. A useful counterfactual reds when the change is reverted. Reverting this change restores a sentence that is misleading but syntactically and structurally identical, so no mechanical assertion can distinguish the two states without quoting the wording. That is the definition of a prose-pinning test, which the repo does not do.

The existing regression guard that does cover the edit. website/test/ssr/docs-links.test.ts boots a real request handler over the website app, scrapes href= from every file under website/app/docs/, and fetches every internal /docs/... link. It therefore renders /docs/suspense and resolves the <a href="/docs/loading-states"> that lives inside the very paragraph being edited. A malformed html template, a stray backtick (invariant 9) or a broken link introduced by the edit reds that suite. Run it, do not extend it:

cd website && npm test

Report the result. Also confirm the grep in step 3 comes back with only the one expected source-comment hit, which is the closest thing to a verification this change admits.

Docs

This IS a documentation change, so the doc gate is satisfied by construction. Every surface touched, in full:

  • website/app/docs/suspense/page.ts:94. The docs-site Suspense page, the primary target.
  • packages/core/AGENTS.md:38. The @webjsdev/core per-package agent-facing module table, the second occurrence.

No other surface needs a change. AGENTS.md, .agents/skills/webjs/SKILL.md, .agents/skills/webjs/references/*.md, packages/cli/templates/**, README.md, the marketing pages under website/, the blog/ posts and docs/ were each grepped for the serializer justification and none carries it. The per-surface verification is enumerated under "Surfaces deliberately NOT touched" in the implementation plan.

The require-docs-with-src.sh gate fires only on staged packages/*/src source. Neither file staged here is under a src/ directory, so the gate does not arm and no bypass env var is needed.

Acceptance criteria

  • website/app/docs/suspense/page.ts gives the hydration-timing reason and no longer names serializer-safety in that parenthetical
  • packages/core/AGENTS.md's webjs-suspense.js row leads with the hydration-timing reason, keeping serializer-safety as the trailing secondary fact
  • The rest of both edited paragraphs is byte-identical to HEAD, including the renderFallback() sentence, the Loading States link, and every other clause of the module-table row
  • packages/core/src/render-server.js is unmodified
  • The step 3 grep returns exactly one hit, packages/core/src/render-server.js:404
  • cd website && npm test passes, including website/test/ssr/docs-links.test.ts
  • cd website && npx webjs check passes
  • The prose hook accepts every commit (no em-dash, no space-surrounded hyphen or semicolon used as a pause, WebJs capitalized where it names the project)
  • No follow-up issue was filed. Anything the work turned up is either fixed in this PR or noted in the PR description
  • The PR body carries Closes #1324

Out of scope

These are non-goals the implementer must not widen into, not deferred work.

  • Rewriting the Suspense docs page. Only the one parenthetical at line 94 changes. The surrounding list, the code sample, the "When to Use Suspense" section and the closing paragraph are correct and stay.
  • Rewriting the packages/core/AGENTS.md module table. Only the one parenthetical in the webjs-suspense.js row changes. Other rows, and the rest of that row, stay.
  • Touching packages/core/src/render-server.js or packages/core/src/component.js. They are the evidence for the correction, not its target. Editing either would arm the source gates over a zero-behaviour change.
  • Changing the data-webjs-fallback mechanism, the data-webjs-prop-* channel, or anything about how <webjs-suspense> streams. This issue documents the existing design correctly. It does not revisit it.
  • Adding a prose-pinning test. Argued in the Tests section. Do not add one to satisfy a habit of shipping a test with every change.
  • Broadening into a general audit of every data-webjs-prop-* mention across the repo. The greps in this issue already enumerated them and found the rest correct. Re-auditing surfaces already cleared here is not part of the task.

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentation

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions