Skip to content

dogfood: SSR does not resolve live() in an attribute hole, so ?open=${live(false)} emits open="" #1443

Description

@vivek7405

Problem

The SSR renderer does not resolve the live() directive in an attribute hole. It resolves it in a child/text hole (render() at packages/core/src/render-server/template-renderer.js:112, streamRender() at :566), but the attribute branches read the raw hole value, so the directive's wrapper object reaches the emit sites unresolved.

All three attribute-hole kinds are wrong. Measured against packages/core/src/render-server.js at ad81d4b3:

?open=${false}           -> <details ></details>            correct
?open=${true}            -> <details open=""></details>     correct
?open=${live(false)}     -> <details open=""></details>     WRONG (should omit)
?open=${live(true)}      -> <details open=""></details>     correct by accident
title=${"hi"}            -> <div title="hi"></div>          correct
title=${live("hi")}      -> <div title="[object Object]">   WRONG
.foo=${1}                -> <my-el data-webjs-prop-foo="1"> correct
.foo=${live(1)}          -> <my-el data-webjs-prop-foo="{&quot;_$webjs&quot;:&quot;live&quot;,&quot;value&quot;:1}">  WRONG

Cause, per kind:

  • bool (:478 buffered, :872 streaming): if (val) out += ${name}=""``. The wrapper object is truthy, so the attribute is emitted whatever the inner value is. A falsy live() can never omit its attribute.
  • attr (:531 and the attr-quoted / attr-unquoted branch at :540): String(val ?? '') stringifies the wrapper to [object Object].
  • prop (:463): await stringify(val) serializes the wrapper itself into data-webjs-prop-*, so the browser applies a {_$webjs:'live', value:...} object as the property instead of the value. Native elements are unaffected (their .prop drops at SSR by design), so this shows only on a custom element.

The client already assumes the server unwraps. packages/core/src/render-client/parts.js applyPart() unwraps live() uniformly at :193, before the attr/bool/prop dispatch. And packages/core/src/render-client/reconciler.js effectiveFormAttr() (:264), whose docstring states "The per-kind rules mirror render-server.js exactly", calls resolveHoleValue() (:295) to unwrap live() when simulating what SSR emitted. So the two renderers hold contradictory models of the same emit, and the form-action reconcile judges on the wrong one.

Observed in production

webjs.dev on mobile paints the nav menu open, then hydration closes it. website/components/site-nav-menu.ts:167 binds ?open=${live(this.open)} on a <details>; this.open is false at SSR, the wrapper is truthy, and the served HTML carries open="". Confirmed on the deployed site and reproduced locally.

This is pre-existing, not from #1430: git diff 673c363b ad81d4b3 -- website/components/site-nav-menu.ts 'packages/core/src/render-server*' 'packages/core/src/directives*' 'packages/core/src/html.js' 'packages/core/src/escape.js' is empty, and the component has not changed since #1223.

Design / approach

Unwrap live() on the server at the same point the client does: once, at the top of the per-hole handling, before the position dispatch. That is a two-line change per renderer and makes the SSR bytes match applyPart() by construction rather than by three parallel per-kind fixes that can drift.

Scope it to live(), which is exactly what the client accepts in attribute position. Every other directive (ref, guard, keyed, cache, until, watch, unsafeHTML, templateContent, asyncAppend, asyncReplace) is handled only inside applyChild on the client, so the server's existing child-position handling in render() / streamRender() already covers them and needs no change. Do not widen the unwrap to other directives: a guard() in an attribute hole is not valid on either side, and silently rendering one on the server only would create a fresh asymmetry.

Unwrapping at the val derivation rather than inside each branch also fixes the comment and rawtext positions (String(val ?? '') at :389 / :395) for free, and is a no-op for the text position, since render() keeps its own isLive branch for a live() nested inside an array.

Implementation notes (for the implementing agent)

Where to edit (both machines in one file, they are the buffered and streaming renderers and must stay identical):

  • packages/core/src/render-server/template-renderer.js
    • buffered machine: val is derived at :382 (let val = values[i]; then the promise await). Unwrap immediately after the await, before the state === 'comment' chain at :387.
    • streaming machine: the same derivation at :822. Apply the identical change.
    • isLive is already imported at :14, so no new import.

Landmines:

  • Unwrap AFTER the promise await, not before: live(await x) is not a thing, but a hole holding a promise that resolves to a live() is, and unwrapping first would miss it.
  • The two machines drift easily. :478 and :872 are the same bool branch written twice; a fix applied to one only will pass renderToString tests and fail renderToStream(v, { ssr: false }). Assert both in the tests.
  • render() (:112) and streamRender() (:566) keep their isLive branches. They are still reached for a live() inside an array child, which the hole-level unwrap does not see.
  • The <webjs-suspense .fallback> special case at :432 calls render(val, ctx), which unwraps anyway. Unaffected either way, but do not reorder the unwrap past it.
  • A live() in a .prop hole on a NATIVE element is dropped at SSR by design (:437), so the prop fix is only observable on a custom element. Write the test against a hyphenated tag.
  • render-server matches the runtime-sensitive pattern in .claude/hooks/require-bun-parity-with-runtime-src.sh:62, so this commit is BLOCKED without a test/bun/** test. That is correct here: SSR string output is exactly the surface that must agree across runtimes.

Invariants to respect:

  • AGENTS.md "html expression prefixes": every hole is identical server and client except @event, .prop on native elements, and a nullish-or-false plain-attribute hole. This change moves live() from violating that rule to obeying it, and does not alter the three documented exceptions. In particular a live(null) in a plain attribute hole must still emit attr="" on the server (the documented server behaviour), not omit it.
  • Invariant 4 (event / property / boolean holes must be unquoted) is untouched.
  • The form-action guards (assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp) must still fire for a function wrapped in live(). Unwrapping first is what MAKES them fire, so add a case for it rather than assuming it.

Tests + docs surfaces:

  • unit: new packages/core/test/rendering/live-in-attribute-hole.test.js, covering bool truthy/falsy, plain attr, attr-quoted, mixed attr, .prop on a custom element, and live(fn) in an action= hole still refusing. Assert against BOTH renderToString and renderToStream(v, { ssr: false }). Counterfactual: reverting the unwrap must red the falsy-bool case.
  • browser: packages/core/test/rendering/browser/ hydration-parity case, an SSR'd ?open=${live(false)} component that hydrates with no attribute change and no flash. This is the layer that actually models the reported bug.
  • bun: test/bun/ cross-runtime assertion that the SSR string for a live() attribute hole is byte-identical on Bun and Node (required by the hook above).
  • docs: website/app/docs/directives/page.ts:33 (the live(value) section) and .agents/skills/webjs/references/components.md:375 gain a line stating live() resolves identically server and client in every hole position. website/app/docs/components/page.ts:702 already describes the native-.prop SSR drop correctly and needs no change. No AGENTS.md change is needed unless the wording above proves inaccurate once implemented.
  • After the fix, website/components/site-nav-menu.ts needs no edit: ?open=${live(false)} will correctly omit the attribute.

Acceptance criteria

  • ?bool=${live(false)} omits the attribute at SSR, matching ?bool=${false}
  • attr=${live(v)} emits v, not [object Object]
  • .prop=${live(v)} on a custom element serializes v, not the wrapper
  • Identical output from renderToString and renderToStream(v, { ssr: false })
  • live() wrapping a server action in an action= / formaction= hole is still refused
  • webjs.dev's nav menu paints closed on mobile with no hydration flash
  • A counterfactual proves the falsy-bool test reds when the unwrap is reverted
  • Tests cover the new behaviour at unit, browser, and Bun layers
  • Docs updated on the two surfaces named above

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions