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="{"_$webjs":"live","value":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
Problem
The SSR renderer does not resolve the
live()directive in an attribute hole. It resolves it in a child/text hole (render()atpackages/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.jsatad81d4b3:Cause, per kind:
:478buffered,:872streaming):if (val) out +=${name}=""``. The wrapper object is truthy, so the attribute is emitted whatever the inner value is. A falsylive()can never omit its attribute.:531and theattr-quoted/attr-unquotedbranch at:540):String(val ?? '')stringifies the wrapper to[object Object].:463):await stringify(val)serializes the wrapper itself intodata-webjs-prop-*, so the browser applies a{_$webjs:'live', value:...}object as the property instead of the value. Native elements are unaffected (their.propdrops 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.jsapplyPart()unwrapslive()uniformly at:193, before the attr/bool/prop dispatch. Andpackages/core/src/render-client/reconciler.jseffectiveFormAttr()(:264), whose docstring states "The per-kind rules mirrorrender-server.jsexactly", callsresolveHoleValue()(:295) to unwraplive()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.devon mobile paints the nav menu open, then hydration closes it.website/components/site-nav-menu.ts:167binds?open=${live(this.open)}on a<details>;this.openisfalseat SSR, the wrapper is truthy, and the served HTML carriesopen="". 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 matchapplyPart()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 insideapplyChildon the client, so the server's existing child-position handling inrender()/streamRender()already covers them and needs no change. Do not widen the unwrap to other directives: aguard()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
valderivation 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, sincerender()keeps its ownisLivebranch for alive()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.jsvalis derived at:382(let val = values[i];then the promise await). Unwrap immediately after the await, before thestate === 'comment'chain at:387.:822. Apply the identical change.isLiveis already imported at:14, so no new import.Landmines:
live(await x)is not a thing, but a hole holding a promise that resolves to alive()is, and unwrapping first would miss it.:478and:872are the same bool branch written twice; a fix applied to one only will passrenderToStringtests and failrenderToStream(v, { ssr: false }). Assert both in the tests.render()(:112) andstreamRender()(:566) keep theirisLivebranches. They are still reached for alive()inside an array child, which the hole-level unwrap does not see.<webjs-suspense .fallback>special case at:432callsrender(val, ctx), which unwraps anyway. Unaffected either way, but do not reorder the unwrap past it.live()in a.prophole 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-servermatches the runtime-sensitive pattern in.claude/hooks/require-bun-parity-with-runtime-src.sh:62, so this commit is BLOCKED without atest/bun/**test. That is correct here: SSR string output is exactly the surface that must agree across runtimes.Invariants to respect:
htmlexpression prefixes": every hole is identical server and client except@event,.propon native elements, and a nullish-or-falseplain-attribute hole. This change moveslive()from violating that rule to obeying it, and does not alter the three documented exceptions. In particular alive(null)in a plain attribute hole must still emitattr=""on the server (the documented server behaviour), not omit it.assertNotFunctionActionAttr,assertNotFunctionReflectedActionProp) must still fire for a function wrapped inlive(). Unwrapping first is what MAKES them fire, so add a case for it rather than assuming it.Tests + docs surfaces:
packages/core/test/rendering/live-in-attribute-hole.test.js, covering bool truthy/falsy, plain attr,attr-quoted, mixed attr,.propon a custom element, andlive(fn)in anaction=hole still refusing. Assert against BOTHrenderToStringandrenderToStream(v, { ssr: false }). Counterfactual: reverting the unwrap must red the falsy-bool case.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.test/bun/cross-runtime assertion that the SSR string for alive()attribute hole is byte-identical on Bun and Node (required by the hook above).website/app/docs/directives/page.ts:33(thelive(value)section) and.agents/skills/webjs/references/components.md:375gain a line statinglive()resolves identically server and client in every hole position.website/app/docs/components/page.ts:702already describes the native-.propSSR drop correctly and needs no change. No AGENTS.md change is needed unless the wording above proves inaccurate once implemented.website/components/site-nav-menu.tsneeds 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)}emitsv, not[object Object].prop=${live(v)}on a custom element serializesv, not the wrapperrenderToStringandrenderToStream(v, { ssr: false })live()wrapping a server action in anaction=/formaction=hole is still refused