fix: make the SSR attribute reader see the browser's attribute set - #1361
Conversation
vivek7405
left a comment
There was a problem hiding this comment.
Went looking for whether the two readers really do agree now, and three places they still do not.
The one that matters most is the resolver fallback. It reads like a shared rule and is not one: the browser only ever calls the client reader with a name from observedAttributes, and that list is exactly d.attribute || hyphenate(k), so the props[name] arm after the loop is dead on the client and live on the server. That is the same read-more-than-the-platform bug as the camelCase case, sitting in the function written to remove it, with a new test pinning it as intended.
The legacy semicolon-less names are the same shape. I took the non-goal on faith from the plan and the plan is wrong about the mechanism: the rule is a one-character lookahead, not tokenizer state, and browsers decode   rather than tolerating it. So it is a live value divergence, documented as a deliberate omission.
The third is smaller but it reds CI, and the comment I left recording why is wrong about there being no alternative.
|
Design rationale: why the resolver derives its match from Two calls here went against the issue's plan, and both came from the same realisation, so they are worth writing down together. The plan framed the fix as three edits plus a shared resolver, and settled each of them by asking what a browser does. That is the right question, and the place it was answered from is what turned out to be unreliable: for both the property-name fallback and the legacy entity names, the plan reasoned about the browser from the client reader's SOURCE. The client's code carried a That is why the resolver now matches that one expression and stops. It is not a smaller version of the old resolver; it is the same expression The legacy semicolon-less names went the same way. The plan recorded them as a deliberate non-goal on two grounds: that nothing decodes them because they are HTML5 parse errors, and that the rule needs tokenizer state a standalone function cannot have. Both are wrong. What I take from both: when a decision rests on "the browser does X", the evidence has to be the browser. Every claim in this PR that names browser behaviour was run against all three engines, and a throwaway differential harness compared the SSR decoder against The third change is smaller and purely structural. |
vivek7405
left a comment
There was a problem hiding this comment.
Checked the fixes from the last round and traced what they touched. The parity work itself holds, but the wider decoder brought in a crash that is worse than the gap it closed.
Reading a 2125-key object literal by name means every lookup goes through Object.prototype, and seven of those inherited names are ordinary-looking references. &constructor; in any attribute of any custom element takes down the component. seedServerAttrs decodes every source attribute, not just the prop-mapped ones, so the blast radius is every element on the page, and the failure mode is the SSR error box rather than a wrong value.
The other two are smaller: a branch that cannot run with a comment saying it handles a real case, and a header comment pointing at the file the resolver just moved out of.
vivek7405
left a comment
There was a problem hiding this comment.
Read the crash fix and traced what it left behind. The fix itself is right: I ran the old and new resolver differentially over roughly 29,000 inputs, every table name and every legacy name against a spread of suffixes, and the only behaviour that moved is the fourteen cases it set out to move.
What is wrong is everything around it. Deleting the prefix walk changed WHY several cases come out literal, and none of the prose caught up: two docblocks in the same file now describe two different algorithms, and four test comments explain rows by a mechanism that no longer exists. Worth fixing rather than shrugging at, because the next person to touch the carve-out will read those comments and conclude the wrong thing about which shapes exercise it.
The coverage gap matters more. The regression this commit fixes is a render-server change with no cross-runtime row and no browser assertion, in a PR where every other entity claim was measured against three engines.
vivek7405
left a comment
There was a problem hiding this comment.
Two more from the same read, both on lines outside this commit's own hunks so they would not anchor inline.
test/bun/attribute-reader-parity.mjs has no row for the shape the crash fix exists to fix. The commit stages render-server.js, which is on the runtime-sensitive list the Bun parity hook matches, and stages no test/bun change, so the regression had no cross-runtime cover at all. Same for the browser layer: ssr-client-parity.test.js had no prototype-name case, while the SSR test file's own header promises a browser half for every case under it. That left "a browser leaves those literal" resting on reasoning in a PR where every other entity claim was measured against three engines.
And packages/core/src/html-entities.js still says it is imported ONLY by render-server.js, which stopped being true when the test started importing it to assert the entry counts.
Both fixed in e29cf437. The prototype names now have eight rows in the cross-runtime script and a browser test that reads getAttribute() off a real upgrade and compares it against the SSR render, and the header names its second importer. Worth noting for whoever looks at the hook: it is wired in .claude/settings.json and did not fire on that commit, so the gate it describes is not currently holding.
e29cf43 to
a04ce4e
Compare
The SSR attribute reader walked the parsed source tag with its own name resolver while the browser goes through observedAttributes, after the parser has lowercased every attribute name and decoded every character reference. The two disagreed on four shapes of hand-written markup, so the SSR'd first paint held one value and the upgraded element held another, with nothing erroring. Both readers now resolve a name through one resolveAttributeProperty in component.js, and the SSR caller supplies the name the platform would deliver. decodeAttrEntities replaces the three-entity unescapeAttr and runs once per attribute, ahead of the type coercion, so every branch gets a decoded value rather than only the JSON one. It is a single-pass replace, so a replacement is never rescanned and `&lt;` is still the literal `<`. Three behaviour changes for an existing app, all of them SSR reading LESS: a state:true prop is no longer populated from a source attribute, a camelCase attribute name in markup no longer resolves, and an attribute matching no declared property is no longer copied onto the instance. All three make SSR agree with what the browser already did, so an app relying on any of them was already broken after hydration. Refs #1341
a04ce4e to
9affab4
Compare
Closes #1341
Summary
The SSR attribute reader and the browser's attribute reader did not see the same set of attributes, so for certain hand-written markup one reader consumed an attribute the other never saw. The SSR'd first paint held one value, the upgraded element held another, and nothing errored.
Both readers now resolve a name through one
resolveAttributePropertyinpackages/core/src/attribute-reader.js, matching exactly thed.attribute || hyphenate(k)expressionobservedAttributesmaps over and nothing else, so the two agree by construction rather than by two copies staying in step. The SSR caller supplies the name the platform would deliver (its lowercased source name). A newdecodeAttrEntitiesreplaces the three-entityunescapeAttrand runs once per attribute, ahead of the type coercion, so every branch gets a decoded value rather than only theObject/Arrayone.What changed for an existing app
Four behaviour changes. The first three are SSR reading LESS:
state: trueprop is no longer populated from a source attribute at SSR;{ attribute: 'is-open' }) no longer also answers to its property name, at SSR;All four make SSR agree with what the browser already did, so an app relying on any of the first three was already broken after hydration. There is no back-compat flag, per the issue.
Merge order
#1340 lands first and owns the VALUE half of the shared reader (
readAttributeValueplus its converter arm). This PR does the NAME half plus the decoder, and deliberately leavesapplyAttrsToInstance's own type-coercion chain in place rather than building #1340's extraction a second time. On the rebase onto main after #1340 merges, that chain becomes areadAttributeValue(def, value)call and #1340'sdecodeparameter is deleted, which is this issue's Step 6.attribute-reader.jsis where that function belongs when it arrives, for the reason its header gives.Deviations from the plan, all deliberate
The entity table has 2125 entries, not the 2124 the issue states.
https://html.spec.whatwg.org/entities.jsoncarries 2231 keys: 2125 semicolon-terminated and 106 semicolon-less. The issue's own 106 figure implies 2125, so the 2124 was off by one. 93 entries map to two code points, as stated.The 106 legacy semicolon-less names ARE decoded, where the issue scoped them out as a non-goal. Both reasons the issue gave for excluding them are wrong, checked against Chromium, Firefox, and WebKit:
 is a recognised name that a browser really does decode (s=" "reaches a reader holding U+00A0, not five literal characters), and the rule that governs it is a one-character lookahead rather than tokenizer state, so it is reproducible here. Leaving them literal would have been a live value divergence of exactly the kind this PR exists to remove, documented as intentional. The decoder decodes a legacy name only when the next character is not=, so =xstays literal; xand¬instay literal for a different reason, because the whole alphanumeric run is captured and neither of those is a legacy name. All three verified against the three engines.The
props[name] || props[camelCase(name)]fallback is REMOVED, where Decision 2 said to keep it. Its stated justification was that the client carries the same fallback so both sides agree on it. They do not: the browser only calls the client reader with a name fromobservedAttributes, and that list holds the declared attribute alone, so the fallback is unreachable on the client and live on the server. Measured,open: prop(Boolean, { attribute: 'is-open' })with<my-el open>SSR'dtrueand upgraded tofalse. Keeping it would have left the headline divergence in the function written to remove it.resolveAttributePropertylives insrc/attribute-reader.js, not incomponent.js(the issue's Step 5 and Step 7). Two guards bracket every mappedexportsentry and./componentis one: #388 requires every runtime named export ofcomponent.jsto be declared incomponent.d.ts, and #1031 requires every declaration reachable fromindex.d.ts(which re-exports it withexport *) to exist onindex.jsat runtime. So a named export added tocomponent.jsis either published as root API or carries the_test-only prefixpackages/core/AGENTS.mddocuments. A module with noexportsentry says what the seam is without overloading a convention that means something else. Nothing is re-exported fromindex.jsorindex-browser.js, per the issue.Test plan
packages/core/test/rendering/ssr-prop-options.test.js): 39 added covering thestateskip, the.propchannel that must keep working, the camelCase and kebab names, the renamed-attribute rule in both directions, the unmapped-attribute case, the full entity matrix onString(named, decimal, hex, missing-semicolon, C1, null, surrogate, over-range, all four legacy shapes, and the three carve-out shapes) plus theObjectandNumberbranches, the two double-decoding counterfactuals as named tests, the sevenObject.prototypenames plus&__proto__;as individually named tests, the legacy-versus-table invariant with both entry counts,getAttribute()throughseedServerAttrs, and a byte-identical assertion on framework-emitted markup captured fromorigin/mainat 207f216.packages/core/test/lifecycle/component-lifecycle.test.js): 2 added for the two client-visible consequences. The existing coercion tests are untouched and green, which is the proof that routing the client through the shared resolver changed nothing.reflect-function-guard.test.jsthat pinned the old divergence. They were written with a note saying The SSR and client attribute readers see different attribute sets #1341 would be the change that made them notice, and it is; they now pin the agreement.packages/core/test/rendering/browser/ssr-client-parity.test.js): 7 added, each through a REAL element upgrade (customElements.whenDefinedplusupdateComplete), then the same markup throughrenderToString, comparing the two. A throwaway differential harness ran 58 entity shapes the same way, comparinggetAttribute()against the SSR decoder, and reported zero mismatches on all three engines.npm run test:browsergreen: Chromium 846, Firefox 836, Webkit 846, 0 failed.test/bun/attribute-reader-parity.mjsplus its.test.mjswrapper): green onnodeand onbun 1.3.14.node scripts/run-bun-tests.js: 303 pass, 27 documented node-only skips, 0 genuine failures.npm test): 4190 pass, 0 fail.Object.prototype, so&constructor;and six sibling names returned a function and threw out of the decoder, painting the SSR error box for any attribute of any custom element, while a browser leaves them literal. It is read through aMapnow. Covered at three layers (seven named SSR tests, eight cross-runtime rows, and a browser test readinggetAttribute()off a real upgrade) and counterfactualled.stateskip reds SSR unit 1, client unit 1, and the Bun script; dropping.toLowerCase()reds the camelCase tests; restoringinstance[propName] = rawreds the unmapped-attribute test; restoring the three-entity decoder reds 22 entity rows and the Bun script; wrapping the decode in a rescan loop reds both double-decoding tests and the Bun script; disabling the legacy arm reds the four legacy rows; removing the lookahead carve-out reds the three carve-out rows plus¬real;; restoring the property-name fallback reds the renamed-attribute test and the Bun script; and reading the entity table by indexing the object rather than through theMapreds all seven prototype-name tests.webjs checkclean onexamples/blogandwebsite;webjs doctor11 passed, 2 warnings, 0 failed on both (the two warnings are pre-existing)./,/docs/components,/ui,/ui/buttonall 200 and no broken modulepreload (7, 12, 12, and 50 preload hints probed). Blog covered by the e2e suite and its smoke tests, green.render-server.jsandhtml-entities.jsare both absent from the builtdist/webjs-core-browser.js, so the table costs the browser nothing.webjs checkrules: N/A. The change is two pure functions plus a data table insiderenderToStringand one class method. No route, network path, navigation, or streaming involvement; the only user-observable surface is SSR-versus-upgrade agreement, which the browser layer asserts directly and more strongly. No scaffold or example-app surface changes, and nowebjs checkrule is added or changed.Docs
website/app/docs/components/page.ts: a coercion bullet for decoding on every type including the legacy rule, the kebab-case rule, the renamed-attribute rule, and thestate: true/.propnote..agents/skills/webjs/references/components.md: thestaterow, the same-attribute-set sentence, and a paragraph on writing attributes in markup.AGENTS.md: one sentence after the property options.Object/Arraybranch paragraphs now say the cases are closed, with identical text in both files, anddecodeAttrEntitiesandattribute-reader.jscarry their own reasoning.packages/cli/templates/.agents/skills/webjs/references/components.mddoes not exist (it is copied atprepack), so there is no scaffold copy to sync.README.mdneeds nothing.