fix: honour converter.fromAttribute in the SSR attribute reader - #1359
Conversation
|
Design rationale: why the shared reader got its own module instead of living in The plan in #1340 put The reason is Three ways out, and why this one:
I took 3. It also drops a module edge instead of adding one: Worth noting #1340 anticipated this shape and set it aside for a reason that does not apply here. It rejected a leaf module because moving The comparison to lit in the issue still holds, just in the other direction, and the module header says so. |
vivek7405
left a comment
There was a problem hiding this comment.
Went looking for whatever this gets wrong and found two things, both real, both now fixed.
The one that matters is the decode. The whole claim of this change is that the two readers agree on a converter, and the first cut handed the SSR converter the literal characters between the quotes while the browser hands it text the DOM already decoded. So the two sides disagreed the moment an attribute carried a quote or an ampersand, which is the exact failure the change is supposed to remove. Worse, it failed loudly in the wrong direction: the documented reason to write a converter is a type the built-ins cannot parse, those parse their input, and escapeAttr encodes every " we emit, so a Map or Date converter threw at SSR on markup we produced ourselves, and since the throw is deliberately uncaught that is an empty component at a 200 in production. Before this change the same component rendered with a wrong-but-present value. Trading a wrong value for a missing component is not a fix. Inline below.
The second was structural and is worth writing down because the plan called it the other way. Putting readAttributeValue in component.js and declaring it in component.d.ts cannot work: index.d.ts:11 re-exports that overlay with a bare export *, so the declaration joins the root public type surface of @webjsdev/core with no runtime export behind it, and import { readAttributeValue } from '@webjsdev/core' type-checks then crashes at load. That is the #1031 phantom guard's whole purpose and it caught it. Dropping the declaration does not work either, since tsc then resolves the import through the overlay and fails. So the reader moved to its own overlay-free module, packages/core/src/attribute-reader.js. The seam stays internal, and render-server.js now reaches the reader without importing component.js at all, which drops an edge instead of adding one. That finding sits path-level, since component.d.ts is no longer in the diff.
Both tests that should have caught these were missing, and both are now there at the unit, browser, and Bun layers. The converter fixtures were all entity-free, so the parity claim was asserted only in the region where it happened to hold, which is how I would expect this class of bug to survive a suite.
vivek7405
left a comment
There was a problem hiding this comment.
Second pass, scoped to the fix commits and their blast radius: both call sites, the escapeAttr / unescapeAttr round trip, the parseAttrs to seedServerAttrs to applyAttrsToInstance ordering, the two overlay guards, the package files and exports, the dev /__webjs/core/* serving path, and the Bun matrix's auto-discovery of the new wrapper.
No functional defect. The unescapeAttr replacement order is a correct inverse of escapeAttr (it undoes & last, so a doubly-encoded value cannot collapse early), and the type branches stay provably neutral because parseAttrs hands them a string for every attribute and never a null.
What it did catch is bookkeeping the module move left behind: three comments still named component.js as the reader's home, and the Bun proof's docblock enumerated three assertions where the file now runs four, so it understated its own coverage. Inline below. The same staleness had reached the PR body, which matters more than a comment because that text becomes the squash-commit body and feeds the generated changelog; fixed there too.
51c90df to
125f411
Compare
A property declaring a custom `converter.fromAttribute` was read one way during SSR and another way the moment the element upgraded in the browser. `attributeChangedCallback` tried the converter first, ahead of any type-based coercion, while `applyAttrsToInstance` dispatched on `def.type` alone and never called the converter at all. So `<my-el mode="a">` with an upper-casing converter painted `a` server-side and held `A` after upgrade. Rather than adding a converter arm to the second reader, the type-dispatch chain moves into one shared `readAttributeValue` in `component.js` that both readers now call. The two chains had already drifted twice (this gap and the unparseable-JSON fallback in #1253), and each drift needed the same edit applied twice in sync. lit is built the same way: `@lit-labs/ssr` forwards its `attributeChangedCallback` into the element's own reader rather than keeping a copy. The extraction is behaviour-neutral for props with no converter, since the SSR reader's input always comes from `parseAttrs`, which yields a string for every attribute and never `null`, so the client's null guards are unreachable from that call site. Behaviour change for existing apps: a `converter.fromAttribute` that previously ran only in the browser now also runs during SSR. One that touches `document`, `window`, or `navigator` will throw server-side where it did not before, and the component renders its error state. A converter that throws is deliberately not caught by either reader, matching the rule `_reflectAttribute` already states for `toAttribute`: an author who supplies a converter owns the conversion. Closes #1340
…ff the public type surface
Two problems with the first cut, both found in review.
The shared reader lived in `component.js` and was declared in
`component.d.ts`, which `index.d.ts` re-exports with a bare `export *`.
That put an internal framework seam on the ROOT public type surface of
`@webjsdev/core` with no matching runtime export, so
`import { readAttributeValue } from '@webjsdev/core'` type-checked and
crashed at load. It moves to its own overlay-free module instead, which
keeps the seam internal and drops the render-server to component edge.
The converter branch was handed the raw source-tag text while the client
is handed text the DOM already decoded, so the two sides read the same
attribute differently the moment it carried a quote or an ampersand. A
converter that parses its input, which is the documented reason to write
one, threw at SSR on markup `escapeAttr` itself produced, and since the
throw is deliberately uncaught that rendered an empty component at a 200.
The decode now applies to the converter branch as well as the JSON one.
The pass-through branches are untouched and stay #1341's.
… run `test/types/dts-export-coverage.test.mjs` writes this file into `test/types/` and removes it when it finishes, so a run killed midway leaves it behind. It is not tracked on main and is not part of this change.
Three comments still named `component.js` as the shared reader's home after it moved, and the Bun proof's docblock enumerated three assertions where the file now runs four.
125f411 to
f003076
Compare
Closes #1340
Summary
A property declaring a custom
converter.fromAttributewas read one way during SSR and another way the moment the element upgraded in the browser.attributeChangedCallbacktried the converter first, ahead of any type-based coercion;applyAttrsToInstancedispatched ondef.typealone and never called the converter at all.<my-el mode="a">with an upper-casing converter paintedaserver-side and heldAafter upgrade.The type-dispatch chain moves into one shared
readAttributeValueinpackages/core/src/attribute-reader.jsthat both readers call, rather than a converter arm bolted onto the second reader. The two chains had already drifted twice (this gap and the unparseable-JSON fallback in #1253), and each drift needed the same edit applied twice in sync. lit is built the same way:@lit-labs/ssrforwards itsattributeChangedCallbackinto the element's own reader rather than keeping a copy.Where the shared reader lives, and why not
component.jsIt is
packages/core/src/attribute-reader.js, a module with no.d.tsoverlay, rather than an export ofcomponent.jsas #1340 planned.packages/core/index.d.ts:11re-exports the component overlay with a bareexport *, so a value declared incomponent.d.tsjoins the ROOT public type surface of@webjsdev/corewhileindex.jsexports no such runtime value, andimport { readAttributeValue } from '@webjsdev/core'type-checks then crashes at load. That is the phantom the #1031 guard exists to catch, and it caught it. Dropping the declaration instead does not work either, since tsc resolves the import through the overlay and fails with TS2305. Re-exporting fromindex.jswould silence the guard by making an internal seam app-facing API, which #1340 explicitly refused. An overlay-free module keeps the seam internal and typed from its own JSDoc, the wayescape.jsandbinding-prefixes.jsalready work, and it drops a module edge rather than adding one:render-server.jsnow reaches the reader without importingcomponent.jsat all. Full reasoning in a comment on this PR and in the module header.Behaviour changes
A
converter.fromAttributethat previously ran only in the browser now also runs during SSR. One that touchesdocument,window, ornavigatorwill throw server-side where it did not before, and the component renders its error state. A converter that throws is deliberately not caught by either reader, matching the rule_reflectAttributealready states fortoAttribute.Both readers hand the converter DECODED attribute text. The client's value comes out of the DOM, which already decoded it, while the SSR reader walks the raw source tag, so passing the raw text through would have made the two sides read the same attribute differently for anything carrying a quote or an ampersand. It would also have failed in the worst direction: the documented reason to write a converter is a type the built-ins cannot parse, those parse their input, and
escapeAttrencodes every"we emit, so a Map or Date converter threw at SSR on markup WebJs itself produced and rendered an empty component at a 200. The decode therefore applies to both branches that PARSE their input (the converter branch and the JSON branch). The pass-through branches (String, Number, Boolean) are untouched.Neutrality for props with no converter
The SSR reader's input always comes from
parseAttrs, which yields a string for every attribute including a bare one ('') and nevernull, so the client's null guards are unreachable from that call site and every branch produces the value it produced before:Number('')is0,'' !== 'false'istrue,JSON.parse('')throws tonull. Pinned by an exact-output test covering String, Number, Boolean, Object, and Array against a bare boolean attribute, an empty-string attribute, unparseable JSON, and an entity-encoded JSON attribute.Merge order
Must merge BEFORE #1341, which edits the same
applyAttrsToInstance. This PR is confined to converter precedence: #1341's three reader-set divergences (thestate: trueprop, the camelCase source attribute, and the three-entity reach ofunescapeAttr) are untouched. The decode is applied only to the branches that parse, which is where the SSR reader already applied it plus the new converter branch that needs it for the same reason; whether it should reach the pass-through branches and whether three entities is enough remain #1341's calls.Test plan
packages/core/test/rendering/ssr-prop-options.test.js: converter runs at SSR ahead of type coercion; converter beats the declared type; converter receives decoded text for an entity-encoded attribute; a throwing converter is not caught and the component renders its SSR error state; a no-converter probe emits an exact expected string.packages/core/test/lifecycle/component-lifecycle.test.js: a throwing converter propagates out ofattributeChangedCallback, pinning the other half of the no-catch decision.packages/core/test/rendering/browser/reflect-function-guard.test.js: two agreement tests through a REAL element upgrade (customElements.whenDefinedplusupdateComplete), one plain and one entity-encoded, each comparing againstrenderToStringof the same markup.npm run test:browsergreen: Chromium 840, Firefox 830, Webkit 840, 0 failed.test/bun/attribute-converter-parity.mjsplus its.test.mjswrapper: converter runs at SSR, beats the declared type, receives decoded text, and a throwing converter is isolated while its sibling still renders. Green under bothnodeandbun 1.3.14.WEBJS_E2E=1 node --test test/e2e/e2e.test.mjsgreen, 94/94.npm test4149/4156, with 5 failures reproduced identically on a cleanorigin/mainworktree (2 Bun listener, 3 elision differential) and one Bun wrapper that resolves the bare@webjsdev/corespecifier to the primary checkout locally; all six pass when resolution points at this branch. They are the known linked-worktree artifacts, not regressions./,/docs/components,/ui,/ui/button, and no broken modulepreload hints. Blog covered by the e2e run above.Docs
website/app/docs/components/page.ts: the converter bypasses the coercion list, runs on both readers, must avoid browser globals, throws uncaught, and receives decoded text..agents/skills/webjs/references/components.md:converterrow plus a paragraph carrying the same facts.AGENTS.md: one sentence in the property-options list.packages/core/src/component.js: thePropertyDeclarationtypedef no longer saysfromAttributeis called inattributeChangedCallbackalone.N/A: the scaffold and its generators (no template or gallery demo declares a
converter), the MCP server (no tool projection changes), the editor plugins (no grammar, snippet, or language-service change), marketing copy,README.md, andwebjs check(no rule added or changed).