Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,14 @@ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditiona
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
| `templateContent(el)` | Render the content of a `<template>` element. |

Every directive here is CLIENT behaviour. At SSR the server renders one shot, so
`guard` always invokes its function, `watch` reads its signal once and inlines
the value, and `live` is fully transparent, resolving to the value it wraps in
every hole position (a child, a plain attribute, a `?bool`, a `.prop`). So
`?open=${live(false)}` omits its attribute exactly as `?open=${false}` does. It
was previously resolved only in a child hole, which served `open=""` and let
hydration close the element a moment later (#1443).

## Display-only elision

A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ jobs:
# machines, which are independent and must be kept in step by hand.
- name: WebJs form-action guard parity on Bun
run: bun test/bun/form-action-guard.mjs
# live() in an attribute hole (#1443): the SSR unwrap must produce
# byte-identical attribute emission on Bun, across both server machines.
# The bug class this guards was a server/client disagreement (a falsy
# `?bool=${live(v)}` emitting its attribute anyway), so a per-runtime
# flavor of the same divergence is the failure worth pinning.
- name: WebJs live()-attribute-hole parity on Bun
run: bun test/bun/live-attribute-hole.mjs
# Form-action dispatch (#1155) on Bun: the 'use server' load hook that
# registers action identity is installed by a different mechanism on each
# runtime (module.registerHooks vs Bun.plugin), and the submission path
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/render-client/parts.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,15 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
let val = mp.statics[0];
for (let j = 0; j < mp.group.length; j++) {
const piece = allValues ? allValues[mp.group[j]] : value;
let piece = allValues ? allValues[mp.group[j]] : value;
// Unwrap live() PER PIECE (#1443). The top-of-function unwrap only
// sees the anchor hole's own value, and every hole inside a QUOTED
// attribute lands here (single-hole included, the compiler classifies
// them all attr-mixed), so without this a `title="${live(v)}"` the
// server now emits correctly would be rewritten to the wrapper's
// stringification on the next client commit. Mirrors the reconciler's
// effectiveFormAttr, which already unwraps each group piece.
if (isLive(piece)) piece = /** @type any */ (piece).value;
// #1154: same function guard for each piece of a mixed attribute.
assertNotFunctionActionAttr(piece, part.name, part.el.localName);
val += String(piece ?? '');
Expand Down Expand Up @@ -487,6 +495,20 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
const marker = part.marker;

// live() in a CHILD position (#1443). applyPart's top-of-function unwrap only
// sees the hole's own value, so a live() nested inside an ARRAY child arrives
// here still wrapped and would stringify to "[object Object]". The server
// recurses through isLive in both machines (template-renderer.js render() and
// streamRender()), so without this the renderers disagree and an SSR'd list
// built with live() per item corrupts on upgrade. A plain unwrap is the whole
// job here: live()'s dirty-check is attribute/property-shaped and has no
// meaning for a child, which is exactly why the server treats it the same way.
// Recursing first (rather than after the unsafeHTML branch) matches the
// server's outcome for a nested directive such as live(unsafeHTML(x)).
if (isLive(value)) {
return applyChildInnerRaw(part, /** @type any */ (value).value, reconcileFormActionsCb);
}

// unsafeHTML directive: inject raw HTML string as DOM nodes.
if (isUnsafeHTML(value)) {
teardownChild(part);
Expand Down Expand Up @@ -1096,6 +1118,9 @@ function removeArrayItem(item) {
* @returns {{ item: ArrayItem, frag: Node | null }}
*/
function buildArrayItem(v, reconcileFormActionsCb) {
// #1443: unwrap live() per ITEM. applyPart unwraps the hole's own value, so
// an array child arrives unwrapped at the top and its ITEMS do not.
if (isLive(v)) v = /** @type any */ (v).value;
if (isTemplate(v)) {
const { inst, frag } = buildDetached(/** @type any */ (v), reconcileFormActionsCb);
return { item: { type: 'tpl', inst }, frag };
Expand Down Expand Up @@ -1153,7 +1178,11 @@ function reconcileArray(part, value, reconcileFormActionsCb) {

try {
for (let i = 0; i < value.length; i++) {
const v = value[i];
// #1443: same per-item unwrap as buildArrayItem, on the in-place update
// path. Without it a re-render writes the wrapper's stringification into
// the existing text node.
const raw = value[i];
const v = isLive(raw) ? /** @type any */ (raw).value : raw;
const o = old[i];
if (isTemplate(v)) {
const tr = /** @type any */ (v);
Expand Down Expand Up @@ -1955,6 +1984,9 @@ async function consumeAsyncStream(state, part, dir, reconcileFormActionsCb) {
* @returns {ChildNode[]}
*/
function renderToNodes(value, reconcileFormActionsCb) {
// #1443: the streamed / detached counterpart of the per-item unwrap above.
// Recursion covers a nested array, since each level re-enters here.
if (isLive(value)) value = /** @type any */ (value).value;
if (value == null || value === false || value === true) return [];
if (isTemplate(value)) {
const tr = /** @type any */ (value);
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/render-server/template-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,18 @@ export async function renderTemplate(tr, ctx) {
if (val && typeof /** @type any */ (val).then === 'function') {
val = await val;
}
// Unwrap live() once, here, before the position dispatch, exactly where
// the client's applyPart() unwraps it (render-client/parts.js). The
// directive's only job is to dirty-check against the LIVE DOM value, a
// client-only concern, so on the server it is transparent and every
// position must see the inner value. Doing it per-position instead let
// the two renderers drift: the wrapper is truthy, so a `?bool=${live(v)}`
// emitted its attribute whatever v was, and `attr=${live(v)}` stringified
// to `[object Object]` (#1443). render() / streamRender() keep their own
// isLive branch below for a live() nested inside an array child, which
// this hole-level unwrap never sees. AFTER the await: a hole may hold a
// promise that resolves TO a live().
if (isLive(val)) val = /** @type any */ (val).value;
if (state === 'comment') {
// Holes inside <!-- comments --> are emitted raw (no escaping; comments
// are inert and not rendered by browsers).
Expand Down Expand Up @@ -823,6 +835,9 @@ export async function streamTemplate(tr, ctx, controller) {
if (val && typeof /** @type any */ (val).then === 'function') {
val = await val;
}
// Same unwrap as the buffered machine above (#1443). The two machines
// must emit identical bytes, so this is not optional here.
if (isLive(val)) val = /** @type any */ (val).value;
if (state === 'comment') {
buf += String(val ?? '');
commentDashes = 0;
Expand Down
177 changes: 177 additions & 0 deletions packages/core/test/rendering/browser/live-attribute-hydration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Real-browser hydration guard for `live()` in an attribute hole (#1443).
*
* The unit tests assert the SSR STRING. This asserts what a reader actually
* saw: a component binding `?open=${live(false)}` was served with `open=""`, so
* the browser painted the region OPEN and hydration then removed the attribute
* and collapsed it. On webjs.dev that was the mobile nav menu flashing open on
* every page load.
*
* A string comparison cannot catch that class of bug alone, because a server
* that emits the attribute and a client that removes it are each internally
* consistent; the defect is the TRANSITION between them. So the assertion is a
* MutationObserver over the attribute across the hydration window: a correct
* hydration touches `open` zero times, because SSR never wrote it.
*
* The window is opened faithfully. The component's real SSR bytes are built
* into a DETACHED container (a custom element does not upgrade until it is
* connected), the observer starts, and only then is the container appended,
* which is the parse-then-upgrade sequence a served page actually takes and the
* exact interval the flash lives in.
*/
import { html } from '../../../src/html.js';
import { render } from '../../../src/render-client.js';
import { WebComponent, prop } from '../../../src/component.js';
import { renderToString } from '../../../src/render-server.js';
import { live } from '../../../src/directives.js';

import { assert } from '../../../../../test/browser-assert.js';

/** The shape webjs.dev's nav menu used: a `<details>` bound through live(). */
class MenuProbe extends WebComponent({ open: prop(Boolean) }) {
constructor() { super(); this.open = false; }
render() { return html`<details ?open=${live(this.open)}><summary>menu</summary></details>`; }
}
MenuProbe.register('lah-menu');

/**
* A QUOTED attribute bound through live(). Every hole inside a quoted attribute
* is attr-mixed to the client compiler, which is the path that reads its pieces
* raw, so this is the shape that corrupts on upgrade rather than at SSR.
*/
class TitleProbe extends WebComponent({ label: prop(String) }) {
constructor() { super(); this.label = 'hello'; }
render() { return html`<b title="${live(this.label)}" class="a ${live(this.label)} z">x</b>`; }
}
TitleProbe.register('lah-title');

/** Same, but open by default, so the guard cannot be met by never emitting. */
class OpenProbe extends WebComponent({ open: prop(Boolean) }) {
constructor() { super(); this.open = true; }
render() { return html`<details ?open=${live(this.open)}><summary>menu</summary></details>`; }
}
OpenProbe.register('lah-open');

/**
* Mount `ssr` through a real upgrade and report every `open` attribute change
* that happened along the way.
*
* Detached first so nothing upgrades before the observer is watching; settled
* afterwards through the element's own update cycle AND a task turn, so a late
* write (which is what the flash is) has genuinely had its chance to land
* before the record is read. Reading earlier would pass vacuously.
*
* @returns {Promise<{ host: Element, writes: (string|null)[] }>}
*/
async function hydrate(ssr) {
const host = document.createElement('div');
host.innerHTML = ssr;

/** @type {(string|null)[]} */
const writes = [];
const observer = new MutationObserver((records) => {
for (const r of records) if (r.attributeName === 'open') writes.push(r.oldValue);
});
observer.observe(host, { attributes: true, subtree: true, attributeOldValue: true, attributeFilter: ['open'] });

document.body.appendChild(host);
const el = host.firstElementChild;
if (el && /** @type any */ (el).updateComplete) await /** @type any */ (el).updateComplete;
await new Promise((r) => setTimeout(r, 0));
observer.disconnect();

return { host, writes };
}

suite('live() in an attribute hole hydrates without a flash (#1443)', () => {
/** @type {Element[]} */
let mounted;

setup(() => { mounted = []; });
teardown(() => { for (const h of mounted) h.remove(); });

test('a falsy ?bool bound through live() is absent from SSR and untouched on upgrade', async () => {
const ssr = await renderToString(html`<lah-menu></lah-menu>`);
assert.ok(
!/\bopen[\s=>]/.test(ssr),
`SSR must not write the attribute for a falsy live() bool, got ${ssr}`,
);

const { host, writes } = await hydrate(ssr);
mounted.push(host);

const details = host.querySelector('details');
assert.ok(details, 'the details element survived hydration');
assert.ok(!details.hasAttribute('open'), 'the hydrated DOM is still closed');
// Before the fix the SSR bytes carried `open=""` and this recorded exactly
// one removal, which IS the visible flash.
assert.equal(
writes.length,
0,
`hydration must not touch the open attribute, but it changed ${writes.length} time(s): ${JSON.stringify(writes)}`,
);
});

test('a truthy ?bool bound through live() is written at SSR and left alone on upgrade', async () => {
const ssr = await renderToString(html`<lah-open></lah-open>`);
assert.match(ssr, /\bopen=""/, `SSR must write the attribute for a truthy live() bool, got ${ssr}`);

const { host, writes } = await hydrate(ssr);
mounted.push(host);

assert.ok(host.querySelector('details').hasAttribute('open'), 'the hydrated DOM is still open');
assert.equal(writes.length, 0, `hydration must not touch the open attribute, got ${JSON.stringify(writes)}`);
});

test('a plain attribute bound through live() carries its value into the live DOM', async () => {
// The `[object Object]` half of the same bug. It is inert (no flash), so
// only the rendered value shows it, and it is asserted in the DOM rather
// than the string so a browser re-parse cannot hide it.
const ssr = await renderToString(html`<div title=${live('hello')}></div>`);
const host = document.createElement('div');
host.innerHTML = ssr;
document.body.appendChild(host);
mounted.push(host);

assert.equal(
host.querySelector('div').getAttribute('title'),
'hello',
'the live() wrapper must not reach the attribute value',
);
});

test('a quoted attribute bound through live() survives the SSR-to-upgrade transition (#1443)', async () => {
// The client half of the same bug, observed as the TRANSITION rather than
// as a bare client render. A quoted attribute hole is attr-mixed to the
// client compiler, and that commit path reads each group piece raw; without
// the per-piece unwrap the correct SSR bytes are rewritten to the wrapper's
// stringification the moment the element upgrades. Serving right and
// corrupting on upgrade is invisible to any SSR-string assertion, so the
// real SSR bytes are mounted and then upgraded here, exactly as a served
// page does it.
const ssr = await renderToString(html`<lah-title></lah-title>`);
assert.match(ssr, /title="hello"/, `SSR must emit the inner value, got ${ssr}`);
assert.ok(!/object Object/.test(ssr), `SSR must not stringify the wrapper, got ${ssr}`);

const { host } = await hydrate(ssr);
mounted.push(host);

const el = host.querySelector('b');
assert.ok(el, 'the element survived hydration');
assert.equal(el.getAttribute('title'), 'hello', 'the quoted hole still holds its value after upgrade');
assert.equal(el.getAttribute('class'), 'a hello z', 'every mixed-attribute piece survived the upgrade');
});

test('a client-only render unwraps live() in quoted and mixed attributes (#1443)', () => {
// The same commit path reached directly, with no SSR involved, which is
// what a post-hydration re-render does.
const host = document.createElement('div');
document.body.appendChild(host);
mounted.push(host);

render(html`<div title="${live('hello')}" class="a ${live('b')} c ${live('d')}"></div>`, host);
const el = host.querySelector('div');
assert.equal(el.getAttribute('title'), 'hello', 'quoted single-hole live() unwraps on the client');
assert.equal(el.getAttribute('class'), 'a b c d', 'every mixed-attribute piece unwraps on the client');
});
});
Loading
Loading