From 35a78dfdae0531f499a95162770630b13963b1e2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 16:48:23 +0530 Subject: [PATCH 1/2] fix: omit a null or undefined attribute hole in SSR, matching the client A plain attribute hole holding null rendered attr="" on the server while the client removed it, so a layout's aria-current=${active ? 'page' : null} served an empty aria-current on every inactive link, and layouts never hydrate to repair it. false disagreed the same way (server "false", client removed). Both renderers now go through one helper, attrHoleValue: null and undefined omit the attribute, false omits it except on an aria-* name, where it writes "false", because a tri-state ARIA attribute set to "false" means something different from an absent one. That is a markup change on both sides: the server stops emitting attr="" and attr="false", and the client now writes aria-*="false" where it used to remove it. The client's form-action model follows, since it predicts what SSR emitted. A bound form's method=${null} is no longer refused for an empty method; it is omitted on both sides and the framework supplies method="post", as it already did for ?method=${false}. A bound submitter's name=${null} likewise leaves the identity's channel free, while name=${''} is still refused. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- packages/core/src/binding-prefixes.js | 28 +++++ packages/core/src/render-client/parts.js | 8 +- packages/core/src/render-client/reconciler.js | 21 ++-- .../src/render-client/template-compiler.js | 6 +- .../src/render-server/template-renderer.js | 14 ++- .../test/rendering/attr-hole-omission.test.js | 115 ++++++++++++++++++ .../browser/ssr-client-parity.test.js | 24 +++- .../form-action-binding-client.test.js | 57 ++++----- .../rendering/form-action-binding.test.js | 24 +++- .../rendering/live-in-attribute-hole.test.js | 9 +- 10 files changed, 251 insertions(+), 55 deletions(-) create mode 100644 packages/core/test/rendering/attr-hole-omission.test.js diff --git a/packages/core/src/binding-prefixes.js b/packages/core/src/binding-prefixes.js index 73cecde03..5c101634a 100644 --- a/packages/core/src/binding-prefixes.js +++ b/packages/core/src/binding-prefixes.js @@ -40,3 +40,31 @@ export const BINDING_PREFIXES = Object.freeze({ export function isBindingPrefix(ch) { return Object.prototype.hasOwnProperty.call(BINDING_PREFIXES, ch); } + +/** + * What a PLAIN attribute hole (`name=${value}`, unquoted, the whole value) + * writes, or `null` when the attribute is omitted. The one rule both renderers + * follow, so the server never serves an attribute the client then removes + * (#1573): + * + * - `null` / `undefined` omit the attribute. This is the "omit" value, so a + * conditional attribute is `aria-current=${active ? 'page' : null}`. + * - `false` omits it too, EXCEPT on an `aria-*` attribute, where it writes + * `"false"`. For a tri-state ARIA attribute (`aria-expanded`, + * `aria-pressed`, `aria-checked`) `"false"` and absent mean different + * things, so the value an author passes is the value the user agent gets. + * - anything else is `String(value)`. + * + * A quoted or mixed value (`title="${x}"`, `class="a ${x}"`) is not a plain + * hole: the author wrote the attribute statically, so it stays and a nullish + * piece reads as empty text. + * + * @param {string} name the attribute name as written + * @param {unknown} value the hole's value (`live()` already unwrapped) + * @returns {string | null} + */ +export function attrHoleValue(name, value) { + if (value == null) return null; + if (value === false) return /^aria-/i.test(name) ? 'false' : null; + return String(value); +} diff --git a/packages/core/src/render-client/parts.js b/packages/core/src/render-client/parts.js index 76fbf2543..65f90b533 100644 --- a/packages/core/src/render-client/parts.js +++ b/packages/core/src/render-client/parts.js @@ -11,6 +11,7 @@ import { SLOT_FALLBACK_FRAG, SLOT_STATE, SLOT_OWNER, applySlotAssignments, withRendererWrites, } from '../slot.js'; import { compile, templateCache } from './template-compiler.js'; +import { attrHoleValue } from '../binding-prefixes.js'; /** * The container the in-progress `render()` is committing into, or null @@ -203,7 +204,10 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb) applyChild(part, value, reconcileFormActionsCb); break; case 'attr': { - if (value == null || value === false) part.el.removeAttribute(part.name); + // #1573: the shared rule, so this agrees with SSR. `null` / `undefined` + // remove, `false` removes except on aria-*, where it writes "false". + const written = attrHoleValue(part.name, value); + if (written === null) part.el.removeAttribute(part.name); else if (isBoundFormAction(value, part.name, part.el.localName)) { // #1155: the ONE supported form-action binding, applied to the live // form exactly as SSR wrote it. A component that ships re-renders its @@ -220,7 +224,7 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb) // (mirrors the SSR guard, so a client re-render cannot write a // server action's source into the live DOM). assertNotFunctionActionAttr(value, part.name, part.el.localName); - part.el.setAttribute(part.name, String(value)); + part.el.setAttribute(part.name, written); } break; } diff --git a/packages/core/src/render-client/reconciler.js b/packages/core/src/render-client/reconciler.js index e5781f757..93c63711d 100644 --- a/packages/core/src/render-client/reconciler.js +++ b/packages/core/src/render-client/reconciler.js @@ -7,6 +7,7 @@ import { assertIdentifiableAction, FORM_ACTION_FIELD, } from '../form-action.js'; import { isLive } from '../directives.js'; +import { attrHoleValue } from '../binding-prefixes.js'; import { RENDERING, SLOT_OWNER, SLOT_STATE, drainRendererBackstop, rescueAssignedNodes } from '../slot.js'; import { compile, submitterActionBindings, INSTANCE } from './template-compiler.js'; import { @@ -132,13 +133,17 @@ function reconcileFormActions(formActions, bound, values) { rec, ); } else { - // What SSR would have emitted for this pass. An attribute hole always - // emits (even `name=${null}`, as `name=""`); a boolean hole emits only - // when truthy. Both identity channels ask the same question, through - // one predicate, so they cannot drift apart again. + // What SSR would have emitted for this pass. A plain attribute hole + // emits unless `attrHoleValue` omits it (`null` / `undefined` / + // `false`, since `name` and `value` are not aria-*, #1573), a mixed + // one always emits, and a boolean hole emits only when truthy. Both + // identity channels ask the same question, through one predicate, so + // they cannot drift apart again. const emits = (parts) => parts.some((np) => (np.kind === 'bool' ? !!resolveHoleValue(values[np.i]) - : true)); + : np.kind === 'attr' + ? attrHoleValue('name', resolveHoleValue(values[np.i])) !== null + : true)); reconcileSubmitterAction( /** @type any */ (part.el), val, rec, emits(rec.nameParts), emits(rec.valueParts), @@ -267,8 +272,10 @@ function effectiveFormAttr(attrParts, staticValue, values) { for (const p of attrParts) { if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT; if (p.kind === 'attr') { - const v = resolveHoleValue(values[p.i]); - return v == null ? '' : String(v); + // #1573: an omitted plain hole is ABSENT, exactly as SSR emits it, so + // `method=${null}` leaves the framework to supply `method="post"`. + const w = attrHoleValue('method', resolveHoleValue(values[p.i])); + return w === null ? ABSENT : w; } // A mixed attribute is the concatenation of its static pieces and EVERY one // of its holes, so the anchor's own value is only part of the answer. diff --git a/packages/core/src/render-client/template-compiler.js b/packages/core/src/render-client/template-compiler.js index c8f34d404..fb65477a3 100644 --- a/packages/core/src/render-client/template-compiler.js +++ b/packages/core/src/render-client/template-compiler.js @@ -440,9 +440,9 @@ function buildFormActionRecord(el, onEl, parts) { /** Prop bindings that cannot converge with SSR; refused when actually bound. */ const propAttrs = []; // `name` holes on a submitter, recorded WITH their kind. Whether one occupies - // the identity's channel is not a property of the hole alone: SSR emits - // `name=""` for an attribute hole whatever it resolved to, but emits nothing - // at all for a FALSY boolean hole and nothing for an `@name` listener. Asking + // the identity's channel is not a property of the hole alone: SSR omits an + // attribute hole that resolved to `null` / `undefined` / `false` (#1573), + // emits nothing for a FALSY boolean hole, and nothing for an `@name` listener. Asking // only "is there a part called name" therefore refused templates SSR renders // happily, which is the render-on-the-server-throw-on-hydration direction. // The kinds travel with the record and `reconcileFormActions` resolves them. diff --git a/packages/core/src/render-server/template-renderer.js b/packages/core/src/render-server/template-renderer.js index fe83b428f..2d315b26c 100644 --- a/packages/core/src/render-server/template-renderer.js +++ b/packages/core/src/render-server/template-renderer.js @@ -1,5 +1,5 @@ import { html, isTemplate } from '../html.js'; -import { BINDING_PREFIXES } from '../binding-prefixes.js'; +import { BINDING_PREFIXES, attrHoleValue } from '../binding-prefixes.js'; import { escapeText, escapeAttr } from '../escape.js'; import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp, @@ -542,7 +542,12 @@ export async function renderTemplate(tr, ctx) { // #1154: never stringify a function into action=/formaction= (it // would serialize a server action's source into the served HTML). assertNotFunctionActionAttr(val, attrName, currentTag); - out += `"${escapeAttr(String(val ?? ''))}"`; + // #1573: the client's rule, not a stringify. `null` / `undefined` + // (and `false` off an aria-* name) omit the attribute, which is + // already in `out` from `attrStart`, so slice it back off. + const written = attrHoleValue(attrName, val); + if (written === null) out = out.slice(0, attrStart); + else out += `"${escapeAttr(written)}"`; state = 'in-tag'; attrName = ''; } @@ -922,7 +927,10 @@ export async function streamTemplate(tr, ctx, controller) { // `renderToStream(v, { ssr: false })`, which no page render uses, so // this covers the public API surface rather than a page leak. assertNotFunctionActionAttr(val, attrName, currentTag); - buf += `"${escapeAttr(String(val ?? ''))}"`; + // #1573: the same omission rule as the buffered renderer above. + const written = attrHoleValue(attrName, val); + if (written === null) buf = buf.slice(0, attrStart); + else buf += `"${escapeAttr(written)}"`; state = 'in-tag'; attrName = ''; } diff --git a/packages/core/test/rendering/attr-hole-omission.test.js b/packages/core/test/rendering/attr-hole-omission.test.js new file mode 100644 index 000000000..deda9324f --- /dev/null +++ b/packages/core/test/rendering/attr-hole-omission.test.js @@ -0,0 +1,115 @@ +/** + * A plain attribute hole that holds `null` / `undefined` / `false` (#1573). + * + * The server used to stringify it (`attr=""`, `attr="false"`) while the client + * removed it, so a layout's `aria-current=${active ? 'page' : null}` served an + * empty `aria-current` on every inactive link, and layouts never hydrate to + * repair it. Both renderers now follow `attrHoleValue` in binding-prefixes.js: + * nullish omits, `false` omits except on an aria-* name, where it is "false". + * + * Every server case runs through BOTH machines (the buffered `renderToString` + * and the streaming `renderToStream(v, { ssr: false })`), since they are + * independent hole dispatchers. The client half runs the real client renderer + * over linkedom. Flat tests, no nested subtests: this file runs in the Bun + * parity matrix, whose node:test shim refuses a nested subtest. + */ +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; +import { html } from '../../src/html.js'; +import { renderToString, renderToStream } from '../../src/render-server.js'; +import { attrHoleValue } from '../../src/binding-prefixes.js'; + +async function drain(stream) { + let out = ''; + const reader = stream.getReader(); + const dec = new TextDecoder(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + out += typeof value === 'string' ? value : dec.decode(value); + } + return out; +} + +async function bothServers(mk, assertOn) { + assertOn(await renderToString(mk()), 'buffered'); + assertOn(await drain(renderToStream(mk(), { ssr: false })), 'streamed'); +} + +let render; +before(async () => { + const { window } = parseHTML(''); + globalThis.document = window.document; + globalThis.DocumentFragment = window.DocumentFragment; + globalThis.Node = window.Node; + globalThis.Element = window.Element; + globalThis.Comment = window.Comment; + globalThis.Text = window.Text; + globalThis.NodeFilter = window.NodeFilter; + globalThis.HTMLElement = window.HTMLElement; + ({ render } = await import('../../src/render-client.js')); +}); + +function clientEl(tpl) { + const host = document.createElement('div'); + render(tpl, host); + return host.firstElementChild; +} + +test('attrHoleValue: the shared rule (#1573)', () => { + assert.equal(attrHoleValue('title', null), null); + assert.equal(attrHoleValue('title', undefined), null); + assert.equal(attrHoleValue('hidden', false), null); + assert.equal(attrHoleValue('aria-expanded', false), 'false'); + assert.equal(attrHoleValue('ARIA-Pressed', false), 'false'); + assert.equal(attrHoleValue('aria-current', null), null); + assert.equal(attrHoleValue('title', 0), '0'); + assert.equal(attrHoleValue('title', ''), ''); + assert.equal(attrHoleValue('aria-expanded', true), 'true'); +}); + +test('server: a null or undefined plain hole omits the attribute (#1573)', async () => { + for (const v of [null, undefined]) { + await bothServers(() => html`x`, (out, who) => { + assert.doesNotMatch(out, /aria-current/, `${who}: aria-current=\${${v}} must be omitted, got ${out}`); + assert.doesNotMatch(out, /title/, `${who}: title=\${${v}} must be omitted, got ${out}`); + assert.match(out, /x<\/a>/, `${who}: the rest of the tag is intact, got ${out}`); + }); + } +}); + +test('server: false omits a plain attribute but writes "false" on aria-* (#1573)', async () => { + await bothServers(() => html``, (out, who) => { + assert.doesNotMatch(out, /data-on/, `${who}: data-on=\${false} must be omitted, got ${out}`); + assert.match(out, /aria-expanded="false"/, `${who}: aria-expanded=\${false} must be "false", got ${out}`); + assert.match(out, /aria-pressed="true"/, `${who}: got ${out}`); + }); +}); + +test('server: a present value, and a quoted or mixed hole, are unchanged (#1573)', async () => { + await bothServers(() => html`x`, (out, who) => { + assert.match(out, /aria-current="page"/, `${who}: got ${out}`); + assert.match(out, /title=""/, `${who}: a QUOTED hole keeps its statically written attribute, got ${out}`); + assert.match(out, /class="a "/, `${who}: a mixed hole keeps the attribute, got ${out}`); + }); +}); + +test('client: the same rule, so hydration changes nothing (#1573)', () => { + const a = clientEl(html`x`); + assert.equal(a.hasAttribute('aria-current'), false); + assert.equal(a.hasAttribute('title'), false); + assert.equal(a.hasAttribute('data-on'), false); + assert.equal(a.getAttribute('aria-expanded'), 'false', 'aria-* false is written, not removed'); +}); + +test('client: an aria-* hole flipping true -> false keeps the attribute as "false" (#1573)', () => { + const host = document.createElement('div'); + const tpl = (v) => html``; + render(tpl(true), host); + assert.equal(host.firstElementChild.getAttribute('aria-pressed'), 'true'); + render(tpl(false), host); + assert.equal(host.firstElementChild.getAttribute('aria-pressed'), 'false'); + render(tpl(null), host); + assert.equal(host.firstElementChild.hasAttribute('aria-pressed'), false, 'null is still the omit value on aria-*'); +}); diff --git a/packages/core/test/rendering/browser/ssr-client-parity.test.js b/packages/core/test/rendering/browser/ssr-client-parity.test.js index 4aefb7767..7b4465de3 100644 --- a/packages/core/test/rendering/browser/ssr-client-parity.test.js +++ b/packages/core/test/rendering/browser/ssr-client-parity.test.js @@ -386,6 +386,24 @@ async function bothWays(tpl) { return { ssr, ssrErr, client, clientErr }; } +suite('SSR/client parity: nullish and false plain attribute holes (#1573)', () => { + test('null / undefined omit, false omits off aria-*, aria-* false is "false"', async () => { + const tpl = () => html`x`; + const attrsOf = (markup) => { + const t = document.createElement('template'); + t.innerHTML = normalize(markup); + const a = t.content.querySelector('a'); + return [...a.attributes].map((x) => `${x.name}="${x.value}"`).sort().join(' '); + }; + const ssr = attrsOf(await renderToString(tpl(), { ssr: true })); + const host = document.createElement('div'); + render(tpl(), host); + const client = attrsOf(host.innerHTML); + assert.equal(client, ssr, 'client and SSR serve the same attribute set'); + assert.equal(ssr, 'aria-expanded="false" aria-pressed="true" href="/x"'); + }); +}); + suite('SSR/client parity: form actions (#1155)', () => { setup(() => { setFormActionResolver((fn) => (fn[FORM_ACTION_ID_KEY] ? ACTION_ID : null)); }); teardown(() => { setFormActionResolver(() => null); }); @@ -400,6 +418,11 @@ suite('SSR/client parity: form actions (#1155)', () => { 'quoted hole-provided method': () => html`
`, 'mixed attribute with NON-EMPTY statics': () => html`
`, 'falsy boolean hole leaves it to the framework': () => html`
`, + // #1573: a nullish plain hole is omitted by BOTH renderers, so it leaves the + // method to the framework exactly as the falsy boolean hole above does. + 'null method hole leaves it to the framework': () => html`
`, + 'undefined enctype hole leaves it to the framework': () => html`
`, + 'null name hole on a bound submitter': () => html`
`, 'case-folded ACTION still binds': () => html`
`, 'a plain url action is an ordinary form': () => html`
`, 'an unbound form keeps its own method': () => html`
`, @@ -461,7 +484,6 @@ suite('SSR/client parity: form actions (#1155)', () => { 'method=get': [() => html`
`, /cannot work/], 'hole-provided method=get after the hole': [() => html`
`, /cannot work/], 'quoted hole-provided method=get': [() => html`
`, /cannot work/], - 'null method hole renders an empty value': [() => html`
`, /cannot work/], 'truthy boolean hole renders an empty value': [() => html`
`, /cannot work/], 'unparseable enctype': [() => html`
`, /cannot work/], 'quoted action hole is a stringify': [() => html`
`, /interpolated into/], diff --git a/packages/core/test/rendering/form-action-binding-client.test.js b/packages/core/test/rendering/form-action-binding-client.test.js index 5a778160f..bc26cfa06 100644 --- a/packages/core/test/rendering/form-action-binding-client.test.js +++ b/packages/core/test/rendering/form-action-binding-client.test.js @@ -174,12 +174,11 @@ test('a re-render that breaks an already-bound form is refused (QUOTED hole)', ( assert.throws(() => render(tpl('get'), host), /cannot work/); }); -test('a hole resolving to null is refused, with SSR\'s own message', () => { - // An attribute hole emits an EMPTY value for null (SSR: `String(val ?? '')`), - // and an empty method cannot submit. What matters is not just that it throws - // but that it throws the SAME thing SSR does: an earlier design invented a - // "lost its method attribute" message here, which no server render could ever - // produce. +test('a hole resolving to null omits the attribute, so the binding supplies it (#1573)', () => { + // A plain attribute hole holding null is the "omit" value in BOTH renderers + // now. An omitted method on a bound form is what `?method=${false}` already + // produced, so WebJs supplies `method="post"` exactly as SSR does, rather + // than refusing an empty method that neither renderer emits any more. for (const attr of ['method', 'enctype']) { const fn = HOISTED(); const host = document.createElement('div'); @@ -187,23 +186,18 @@ test('a hole resolving to null is refused, with SSR\'s own message', () => { ? (v) => html`
` : (v) => html`
`; render(tpl(attr === 'method' ? 'post' : 'multipart/form-data'), host); - assert.throws( - () => render(tpl(null), host), - new RegExp(`
cannot work`), - `a null ${attr} hole must be refused the way SSR refuses it`, - ); + render(tpl(null), host); + const form = host.querySelector('form'); + assert.equal(form.getAttribute('method'), 'post', `a null ${attr} hole leaves the supplied method`); + if (attr === 'enctype') assert.notEqual(form.getAttribute('enctype'), '', 'no empty enctype is left behind'); } }); -test('a null hole is refused on the FIRST render too, not only a re-render', () => { - // The old write-tracking design only noticed this on a re-render, because its - // check ran before the form was registered as bound. SSR always threw. +test('a null method hole is accepted on the FIRST render too (#1573)', () => { const fn = HOISTED(); const host = document.createElement('div'); - assert.throws( - () => render(html`
`, host), - /cannot work/, - ); + render(html`
`, host); + assert.equal(host.querySelector('form').getAttribute('method'), 'post'); }); test('an action hole that resolves to a URL releases the binding', () => { @@ -765,21 +759,22 @@ test('a .name / .value on a NON-binding submitter is left alone', () => { }); test('an empty name PART on a bound submitter is refused, matching SSR', () => { - // Judged on the part, not on what it resolved to. `name=${null}` leaves no - // attribute on the client while SSR emits `name=""` beside the identity, so - // reading the live value back returned '' and the client waved through a - // template SSR hard-refuses. + // `name=${''}` emits `name=""` beside the identity on both renderers, so it + // is refused. `null` / `undefined` omit the attribute on both (#1573), so the + // identity's channel is free and the template is accepted. const formAction = HOISTED(); const buttonAction = HOISTED(); - for (const value of [null, '', undefined]) { - assert.throws( - () => render( - html`
`, - document.createElement('div'), - ), - /already carries a "name" attribute/, - `name=\${${String(value)}}`, - ); + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /already carries a "name" attribute/, + ); + for (const value of [null, undefined]) { + const host = document.createElement('div'); + render(html`
`, host); + assert.equal(host.querySelector('button').getAttribute('name'), '__webjs_action', `name=\${${String(value)}} leaves the identity's channel free`); } }); diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index b4a7f9643..c9cac51bd 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -895,19 +895,37 @@ test('the submitter .prop refusal does not fire on ordinary controls', async () }); test('an empty author name after the hole is refused, not shipped as a duplicate', async () => { - // `name=${null}` emits `name=""`. The parse keeps the LAST duplicate, so + // `name=${''}` emits `name=""`. The parse keeps the LAST duplicate, so // reading the value back found `''` and waved through a tag carrying TWO // `name` attributes. A browser keeps the FIRST, so whichever came first would // silently win, and SSR would ship markup the client never produces. withResolver(); for (const tpl of [ - html`
`, - html`
`, + html`
`, + html`
`, ]) { await assert.rejects(() => renderToString(tpl, { ssr: true }), /already carries a "name" attribute/); } }); +test('a null name hole on a bound submitter is omitted, so the identity is the only name (#1573)', async () => { + withResolver(); + for (const tpl of [ + html`
`, + html`
`, + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.equal((out.match(/]*>/)[0].match(/\bname=/g) || []).length, 1, out); + } +}); + +test('a null method hole on a bound form gets the supplied method="post" (#1573)', async () => { + withResolver(); + const out = await renderToString(html`
`, { ssr: true }); + assert.match(out, /]*method="post"/); + assert.doesNotMatch(out, /method=""/); +}); + test('a .prop on a submitter that binds NOTHING is an ordinary native property', async () => { // #1307 narrowed this. `.formMethod` / `.formEnctype` / `.formAction` on a // button that binds no action used to be refused, on the grounds that they diff --git a/packages/core/test/rendering/live-in-attribute-hole.test.js b/packages/core/test/rendering/live-in-attribute-hole.test.js index 5355961e3..68c718852 100644 --- a/packages/core/test/rendering/live-in-attribute-hole.test.js +++ b/packages/core/test/rendering/live-in-attribute-hole.test.js @@ -105,12 +105,11 @@ test('a mixed attribute hole resolves live() in every position (#1443)', async ( }); }); -test('a null attribute hole keeps the documented empty-string emit through live() (#1443)', async () => { - // AGENTS.md: the server STRINGIFIES a nullish plain-attribute hole to - // `attr=""` (only the client removes it). Unwrapping must not quietly change - // that documented asymmetry into an omission. +test('a null attribute hole is omitted through live() exactly like a bare null (#1443, #1573)', async () => { + // A nullish plain-attribute hole omits the attribute on both renderers + // (#1573). Unwrapping live() must reach that rule, not stringify the wrapper. await bothRenderers(() => html`
`, (out, who) => { - assert.match(out, /title=""/, `${who}: live(null) must emit title="" like a bare null, got ${out}`); + assert.doesNotMatch(out, /title=/, `${who}: live(null) must omit title like a bare null, got ${out}`); }); }); From ed5082205238e92150c1557b1d8ac6b3222ed62d Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 16:49:41 +0530 Subject: [PATCH 2/2] docs: describe the shared null and false attribute-hole rule Every surface that documented the server and client disagreeing on a nullish or false attribute hole told agents to branch templates around it. With one rule in both renderers, null is the omit value and aria-* false is "false", so the docs now teach the conditional-attribute idiom directly and the bound form's method=${null} as supplied rather than refused. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .agents/skills/webjs/references/components.md | 2 +- .../skills/webjs/references/muscle-memory-gotchas.md | 2 +- AGENTS.md | 4 ++-- packages/ui/AGENTS.md | 12 +++++++----- website/app/docs/components/page.ts | 4 ++-- website/app/docs/ssr/page.ts | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 2885edeb3..340d8eb5c 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -30,7 +30,7 @@ Most of what follows restates widely held component-model advice, ported. Lit's **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule: -- Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers. +- A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false. - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path. **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `