Skip to content

fix!: omit a null or undefined attribute hole in SSR, matching the client - #1574

Merged
vivek7405 merged 3 commits into
mainfrom
fix/ssr-omit-nullish-attr
Oct 7, 2026
Merged

vivek7405 merged 3 commits into
mainfrom
fix/ssr-omit-nullish-attr

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Closes #1573

A plain attribute hole holding null or undefined is now omitted by the server, matching the client, so aria-current=${active ? 'page' : null} no longer serves aria-current="" on every inactive link. false follows one rule on both renderers too: it omits the attribute, except on an aria-* name, where it is written as "false".

What changed

  • attrHoleValue(name, value) in packages/core/src/binding-prefixes.js is the single rule. Both server machines (buffered and streaming) and the client applyPart use it.
  • The client's form-action model (effectiveFormAttr, the submitter emits predicate) predicts SSR output, so it follows the same rule.
  • Quoted and mixed holes (title="${x}", class="a ${x}") are unchanged: the author wrote the attribute statically, so it stays.

Breaking

  • Server markup: attr=${null} / attr=${undefined} no longer emit attr="", and attr=${false} on a non-aria attribute no longer emits attr="false".
  • Client markup: aria-x=${false} now writes aria-x="false" instead of removing it.
  • A bound form's method=${null} / enctype=${null} is no longer refused; it is omitted and the framework supplies method="post", exactly as for ?method=${false}. A bound submitter's name=${null} is accepted (the identity takes the channel); name=${''} is still refused.

Decision on false

Omit everywhere except aria-*, where both renderers write "false". For a tri-state ARIA attribute (aria-expanded, aria-pressed, aria-checked) "false" and absent mean different things, so passing the boolean straight through is the useful behaviour. For every other attribute, false reading as "omit" matches what the client already did and what ?attr does.

Test plan

  • New packages/core/test/rendering/attr-hole-omission.test.js: the helper, both server machines, and the client (null, undefined, false, aria false, quoted and mixed unchanged). Counterfactual: restoring either the old server renderer or the old parts.js turns 2 of its 6 tests red.
  • Form-action unit tests rewritten for the new semantics, plus a SSR test that method=${null} gets method="post".
  • Browser parity (ssr-client-parity.test.js, Chromium/Firefox/WebKit, 78 passed): new attribute-set parity test and three ACCEPTS rows moved from REFUSES.
  • Full npm test: no new failures (the same 7 pre-existing linked-worktree failures as on main).
  • Bun: the new and touched tests pass under bun test.
  • webjs check on the website.

Docs

Updated AGENTS.md (hole paragraph and invariant 12), .agents/skills/webjs/references/components.md and muscle-memory-gotchas.md, packages/ui/AGENTS.md, and the docs site components and ssr pages. The kit components that branch around the old behaviour keep their branches, since an app may run an older core.

https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo

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
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
@vivek7405 vivek7405 self-assigned this Oct 7, 2026
@pilots-run

pilots-run Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Preview for 907eba4 was not built: compose file has unsupported keys

Next: fix the listed keys in the compose file

@vivek7405
vivek7405 merged commit 2a9613c into main Oct 7, 2026
9 of 11 checks passed
@vivek7405
vivek7405 deleted the fix/ssr-omit-nullish-attr branch October 7, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSR omits a null or undefined attribute hole, matching the client

1 participant