Skip to content

Commit 2c4c98e

Browse files
committed
feat(core): SSR property bindings via data-webjs-prop-* side-channel
A property hole (.prop=${val}) on any element used to be dropped silently at SSR. The data was lost between page function and child component, breaking the natural "fetch data, pass to component" pattern unless authors manually JSON.stringify into an attribute. This commit makes property bindings round-trip through SSR. Server-side emit. The renderer encodes each property hole via the wire serializer (handles Array, Object, Date, Map, Set, BigInt, cycles via @webjskit/core/serialize) and emits as data-webjs-prop-<kebab>="<encoded>" on the element. Event holes (@click) still drop because they are client-only by design; boolean holes (?disabled) keep their existing behaviour. Server-side consume. The component walker reads data-webjs-prop-* attributes, decodes them via the wire format, and sets them on the instance with priority over the string-typed attribute path. Render() now sees the original JS reference, not a coerced string. Async work inside render still works (Promise values are awaited before serialization at the hole site). Unserializable values (functions, class instances with private state, DOM nodes) drop with a single-line console warning instead of crashing the SSR pass. Same constraint as Next.js Server Components. Updated the existing 'drops properties on server' test to assert the new behaviour: the attribute is present and contains the serialized value.
1 parent c686452 commit 2c4c98e

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

packages/core/src/render-server.js

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { stylesToString, isCSS } from './css.js';
55
import { isRepeat } from './repeat.js';
66
import { isSuspense } from './suspense.js';
77
import { isUnsafeHTML, isLive } from './directives.js';
8+
import { stringify, parse } from './serialize.js';
89

910
/**
1011
* Render a TemplateResult (or any renderable value) to an HTML string.
@@ -195,10 +196,33 @@ async function renderTemplate(tr, ctx) {
195196
} else if (state === 'after-eq') {
196197
const prefix = attrName[0];
197198
const name = attrName.slice(1);
198-
if (prefix === '@' || prefix === '.') {
199+
if (prefix === '@') {
200+
// Event listener. Client-only behaviour, drop at SSR.
199201
out = out.slice(0, attrStart);
200202
state = 'in-tag';
201203
attrName = '';
204+
} 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.
210+
out = out.slice(0, attrStart);
211+
try {
212+
const encoded = await stringify(val);
213+
out += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
214+
} catch (e) {
215+
// Unserializable value (function, class instance with
216+
// private state, DOM node, etc.). Drop with a warning so
217+
// SSR does not crash. Same constraint as Next.js RSC.
218+
console.warn(
219+
`[webjs] property binding .${name} has an unserializable `
220+
+ `value during SSR. Dropping. The browser will see the `
221+
+ `property as undefined. Detail: ${e && e.message}`
222+
);
223+
}
224+
state = 'in-tag';
225+
attrName = '';
202226
} else if (prefix === '?') {
203227
out = out.slice(0, attrStart);
204228
if (val) out += `${name}=""`;
@@ -256,7 +280,14 @@ async function injectDSD(html, ctx) {
256280
const isShadow = /** @type any */ (Cls).shadow === true;
257281
const instance = new /** @type any */ (Cls)();
258282
const attrMap = parseAttrs(attrs);
283+
// Decode `data-webjs-prop-*` attributes first (rich-typed values
284+
// emitted for `.prop=${val}` bindings in the parent template),
285+
// then coerce the ordinary string attributes by `static
286+
// properties` type. Property bindings take priority on a name
287+
// collision because they preserve the original JS reference.
288+
const propValues = consumePropAttrs(attrMap);
259289
applyAttrsToInstance(instance, attrMap, Cls);
290+
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
260291
let tpl = instance.render ? instance.render() : '';
261292
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
262293
// Render the template to HTML. injectDSD recurses on the result so
@@ -643,6 +674,45 @@ function camelCase(s) {
643674
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
644675
}
645676

677+
/**
678+
* Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
679+
* Used to serialize property-binding names into HTML attribute names,
680+
* which are case-insensitive in the parser. The original JS property
681+
* name is recovered via camelCase() on the consumer side.
682+
*
683+
* @param {string} s
684+
*/
685+
function kebabCase(s) {
686+
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
687+
}
688+
689+
/**
690+
* Decode `data-webjs-prop-<kebab>` attributes from a parsed attribute
691+
* map, returning a map of camelCase property name to decoded value.
692+
* Mutates `attrs` by deleting the consumed entries so they do not
693+
* appear in the rendered output a second time.
694+
*
695+
* @param {Record<string,string>} attrs
696+
* @returns {Record<string, unknown>}
697+
*/
698+
function consumePropAttrs(attrs) {
699+
/** @type {Record<string, unknown>} */
700+
const props = {};
701+
for (const key of Object.keys(attrs)) {
702+
if (!key.startsWith('data-webjs-prop-')) continue;
703+
const propName = camelCase(key.slice('data-webjs-prop-'.length));
704+
try {
705+
props[propName] = parse(attrs[key]);
706+
} catch {
707+
// Malformed payload. Skip silently so the rest of the component
708+
// can still render. The client-side hydration will also try and
709+
// fail, which is fine: undefined-prop semantics.
710+
}
711+
delete attrs[key];
712+
}
713+
return props;
714+
}
715+
646716
// ---------------------------------------------------------------------------
647717
// Streaming renderer
648718
// ---------------------------------------------------------------------------

test/render-server.test.js

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

29-
test('drops properties on server', async () => {
30-
assert.equal(await renderToString(html`<input .value=${'typed'} />`), '<input />');
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[^"]*"/);
3135
});
3236

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

0 commit comments

Comments
 (0)