Skip to content

Commit a3e1132

Browse files
committed
fix(core): skip property-attribute emission for native elements + decode HTML entities on server walker read
Two related fixes to the property-binding SSR path. 1. Native-element guard. Property bindings on tags without a hyphen (`<input .value=${v}>`) used to emit a `data-webjs-prop-value` attribute, but nothing consumes it: the SSR walker only runs for custom elements, and on the browser side, page-template re-execution does not happen (page functions are server-only). The attribute was dead weight. Skip emission for non-custom-element tags; the property hole drops the same way it did before this branch. 2. HTML-entity decode in consumePropAttrs. parseAttrs returns the literal characters between the quote marks; `escapeAttr` had converted `"` to `&quot;` on the way out, so the walker was passing the still-escaped JSON to `parse()` and failing silently. Added an `unescapeAttr` helper that reverses the server-side escape (order matters: `&lt;`, then `&quot;`, then `&amp;`). The browser handles this automatically, so client-side hydration was unaffected. Updated the `drops properties on server` snapshot test to its new behavior name and assertion: native-element property bindings stay dropped.
1 parent e8e4383 commit a3e1132

2 files changed

Lines changed: 40 additions & 12 deletions

File tree

packages/core/src/render-server.js

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,22 @@ async function renderTemplate(tr, ctx) {
202202
state = 'in-tag';
203203
attrName = '';
204204
} else if (prefix === '.') {
205-
// Property binding. Serialize via the wire format and emit
206-
// as a `data-webjs-prop-<kebab-name>` side-channel attribute.
207-
// The SSR walker reads it before calling instance.render();
208-
// the client renderer applies + strips it on hydration so
209-
// the settled DOM is clean.
205+
// Property binding. Only meaningful on custom elements (which
206+
// have a hyphen in the tag name and a WebComponent subclass
207+
// that knows how to apply + strip data-webjs-prop-* on
208+
// hydration). For native elements (`<input .value=${v}>`)
209+
// the attribute would be dead weight (nothing consumes it),
210+
// so we drop it the same way the old behaviour did. The
211+
// client renderer still applies the property when the
212+
// template runs in the browser, which is the only place a
213+
// page-level `.prop` on a native element could have set the
214+
// property to begin with.
210215
out = out.slice(0, attrStart);
216+
if (!currentTag.includes('-')) {
217+
state = 'in-tag';
218+
attrName = '';
219+
continue;
220+
}
211221
try {
212222
const encoded = await stringify(val);
213223
out += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
@@ -686,6 +696,22 @@ function kebabCase(s) {
686696
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
687697
}
688698

699+
/**
700+
* Reverse `escapeAttr` on a server-side attribute value. Needed
701+
* because `parseAttrs` returns the literal characters between the
702+
* quote marks; HTML entities are not decoded by the regex. The
703+
* browser handles this automatically, so client-side reads via
704+
* `getAttribute()` do not need the same step.
705+
*
706+
* @param {string} s
707+
*/
708+
function unescapeAttr(s) {
709+
return s
710+
.replace(/&lt;/g, '<')
711+
.replace(/&quot;/g, '"')
712+
.replace(/&amp;/g, '&');
713+
}
714+
689715
/**
690716
* Decode `data-webjs-prop-<kebab>` attributes from a parsed attribute
691717
* map, returning a map of camelCase property name to decoded value.
@@ -702,7 +728,7 @@ function consumePropAttrs(attrs) {
702728
if (!key.startsWith('data-webjs-prop-')) continue;
703729
const propName = camelCase(key.slice('data-webjs-prop-'.length));
704730
try {
705-
props[propName] = parse(attrs[key]);
731+
props[propName] = parse(unescapeAttr(attrs[key]));
706732
} catch {
707733
// Malformed payload. Skip silently so the rest of the component
708734
// can still render. The client-side hydration will also try and

test/render-server.test.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ test('drops event handlers on server', async () => {
2626
assert.equal(await renderToString(html`<button @click=${() => {}}>go</button>`), '<button >go</button>');
2727
});
2828

29-
test('property bindings serialize into data-webjs-prop-* attributes on server', async () => {
30-
// Previously these holes were dropped (lossy). Now they survive SSR
31-
// via a side-channel attribute that the SSR walker reads for custom
32-
// elements and the client renderer applies + strips on hydration.
33-
const out = await renderToString(html`<input .value=${'typed'} />`);
34-
assert.match(out, /data-webjs-prop-value="[^"]*typed[^"]*"/);
29+
test('property bindings on NATIVE elements drop on server (no consumer for them)', async () => {
30+
// Native elements (`<input>`) have no SSR walker to construct an
31+
// instance from. Emitting `data-webjs-prop-*` would be dead weight
32+
// because nothing consumes it on the server or in the browser
33+
// (the property is set by the client renderer when the same
34+
// template runs in the browser, not from this attribute). So the
35+
// hole still drops at SSR.
36+
assert.equal(await renderToString(html`<input .value=${'typed'} />`), '<input />');
3537
});
3638

3739
test('boolean attribute renders only when truthy', async () => {

0 commit comments

Comments
 (0)