@webjsdev/core 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index-browser.js CHANGED
@@ -48,8 +48,16 @@ export { WebjsFrame } from './src/webjs-frame.js';
48
48
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
49
49
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
50
50
 
51
- // Directives, also available via '@webjsdev/core/directives'
52
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
51
+ // Directives, also available via '@webjsdev/core/directives'. The full
52
+ // lit-html-parity set is re-exported here so the dist browser bundle (which
53
+ // the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
54
+ // every directive, matching what src/directives.js exports in dev.
55
+ export {
56
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
57
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
58
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
59
+ watch, isWatch,
60
+ } from './src/directives.js';
53
61
 
54
62
  // Context Protocol, also available via '@webjsdev/core/context'
55
63
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
package/index.js CHANGED
@@ -30,8 +30,15 @@ export { WebjsFrame } from './src/webjs-frame.js';
30
30
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
31
31
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
32
32
 
33
- // Directives, also available via '@webjsdev/core/directives'
34
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
33
+ // Directives, also available via '@webjsdev/core/directives'. The full
34
+ // lit-html-parity set is re-exported so the bare specifier exposes the same
35
+ // directive surface in Node as the browser bundle does.
36
+ export {
37
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
38
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
39
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
40
+ watch, isWatch,
41
+ } from './src/directives.js';
35
42
 
36
43
  // Context Protocol, also available via '@webjsdev/core/context'
37
44
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -147,8 +147,257 @@ function defaultHasChanged(a, b) {
147
147
  * ```
148
148
  */
149
149
 
150
- // Base class choice: real HTMLElement on the browser, a dummy on the server.
151
- const Base = isBrowser ? HTMLElement : /** @type {any} */ (class {});
150
+ /**
151
+ * Inert `ElementInternals`-shaped object returned by the server shim's
152
+ * `attachInternals()`. Modeled on `@lit-labs/ssr-dom-shim`'s
153
+ * `ElementInternalsShim` (lit repo, `packages/labs/ssr-dom-shim/src/lib/
154
+ * element-internals.ts`): form-association, validity, and custom-state
155
+ * calls are no-ops at SSR (no form, no constraint validation, no `:state()`
156
+ * matching server-side), so a component that calls `this.attachInternals()`
157
+ * in its constructor renders instead of crashing. The browser runs the real
158
+ * `attachInternals()` on hydration.
159
+ *
160
+ * Deliberate deviation from lit: lit's `checkValidity` / `reportValidity`
161
+ * THROW on the server. webjs returns `true` instead, to keep SSR
162
+ * progressive-enhancement-safe (a stray validity call in a constructor must
163
+ * not 500 the page); the browser does the real validation.
164
+ * @returns {any}
165
+ */
166
+ function makeServerInternals() {
167
+ return {
168
+ states: new Set(),
169
+ shadowRoot: null,
170
+ form: null,
171
+ labels: [],
172
+ role: '',
173
+ willValidate: true,
174
+ validity: /** @type {any} */ ({}),
175
+ validationMessage: '',
176
+ setFormValue() {},
177
+ setValidity() {},
178
+ checkValidity() { return true; },
179
+ reportValidity() { return true; },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Server-side stand-in for `HTMLElement`. The SSR pipeline constructs
185
+ * component instances in Node, where `HTMLElement` does not exist, so the
186
+ * base class is this shim. It is modeled on `@lit-labs/ssr-dom-shim`'s
187
+ * `ElementShim` (lit repo, `packages/labs/ssr-dom-shim/src/index.ts`): the
188
+ * attribute methods (`getAttribute` / `setAttribute` / `hasAttribute` /
189
+ * `removeAttribute` / `toggleAttribute`, the `attributes` getter) are backed
190
+ * by a Map, so lit muscle-memory patterns that read attributes in `render()`
191
+ * or set them while deriving state work server-side; the SSR walker seeds
192
+ * the Map from the element's source attributes and reads it back to surface
193
+ * reflected/added attributes in the output. Event methods are no-ops (no
194
+ * server event loop), and `attachInternals()` returns the inert object
195
+ * above. The genuinely browser-only surface (`querySelector`, layout reads,
196
+ * `attachShadow`, `focus`) is deliberately absent and still throws at SSR,
197
+ * which the `no-browser-globals-in-render` rule and the SSR crash hint flag.
198
+ *
199
+ * Deliberate deviation from lit: this shim lowercases attribute names so
200
+ * `getAttribute('Foo')` after `setAttribute('foo', x)` resolves, matching how
201
+ * a real browser treats HTML attribute names as case-insensitive. lit's shim
202
+ * keys the Map by the raw name (a known fidelity gap in lit-labs).
203
+ */
204
+ class ServerElement {
205
+ constructor() {
206
+ /**
207
+ * Backing store for the attribute methods. Keys are lowercased
208
+ * attribute names (HTML attributes are case-insensitive). Seeded by the
209
+ * SSR walker from the element's source attributes.
210
+ * @type {Map<string, string>}
211
+ */
212
+ this.__ssrAttrs = new Map();
213
+ /** @type {any} */
214
+ this.__internals = null;
215
+ }
216
+
217
+ /** Mirrors `Element.attributes`: an array of `{ name, value }`. */
218
+ get attributes() {
219
+ return [...this.__ssrAttrs].map(([name, value]) => ({ name, value }));
220
+ }
221
+
222
+ /** @param {string} name */
223
+ getAttribute(name) {
224
+ const v = this.__ssrAttrs.get(String(name).toLowerCase());
225
+ return v === undefined ? null : v;
226
+ }
227
+
228
+ /** @param {string} name @param {unknown} value */
229
+ setAttribute(name, value) {
230
+ // Emulate the browser casting all values to string (lit does the same).
231
+ this.__ssrAttrs.set(String(name).toLowerCase(), String(value));
232
+ }
233
+
234
+ /** @param {string} name */
235
+ removeAttribute(name) {
236
+ this.__ssrAttrs.delete(String(name).toLowerCase());
237
+ }
238
+
239
+ /** @param {string} name */
240
+ hasAttribute(name) {
241
+ return this.__ssrAttrs.has(String(name).toLowerCase());
242
+ }
243
+
244
+ /** @param {string} name @param {boolean} [force] */
245
+ toggleAttribute(name, force) {
246
+ // Steps mirror https://dom.spec.whatwg.org/#dom-element-toggleattribute
247
+ const key = String(name).toLowerCase();
248
+ const present = this.__ssrAttrs.has(key);
249
+ const next = force === undefined ? !present : force;
250
+ if (next) {
251
+ this.__ssrAttrs.set(key, '');
252
+ return true;
253
+ }
254
+ this.__ssrAttrs.delete(key);
255
+ return false;
256
+ }
257
+
258
+ /** @returns {string[]} */
259
+ getAttributeNames() {
260
+ return [...this.__ssrAttrs.keys()];
261
+ }
262
+
263
+ /**
264
+ * Minimal `Element.closest()` for SSR. The SSR walker threads the chain
265
+ * of enclosing custom-element instances into each instance
266
+ * (`__ssrAncestors`); this walks self-then-ancestors and returns the
267
+ * nearest whose tag matches. Only bare tag-name selectors are supported
268
+ * server-side (`closest('ui-tabs')`), which is what compound components
269
+ * need to read parent state for a correct first paint; anything more
270
+ * specific (class, attribute, or descendant selectors) returns null,
271
+ * matching the pre-shim behaviour. The browser runs the real `closest()`
272
+ * on hydration.
273
+ * @param {string} selector
274
+ * @returns {any}
275
+ */
276
+ closest(selector) {
277
+ const sel = String(selector).trim().toLowerCase();
278
+ // Tag-name selectors only at SSR; bail (null) on anything else.
279
+ if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null;
280
+ if (this.__ssrTag === sel) return this;
281
+ const chain = this.__ssrAncestors;
282
+ if (!Array.isArray(chain)) return null;
283
+ for (let i = chain.length - 1; i >= 0; i--) {
284
+ if (chain[i] && chain[i].__ssrTag === sel) return chain[i];
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * `HTMLElement.dataset`: a live view over the element's `data-*`
291
+ * attributes, backed by the SSR attribute Map. Reading / writing
292
+ * `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase
293
+ * to kebab-case), so a `render()` that sets `this.dataset.state = 'on'`
294
+ * surfaces `data-state="on"` in the SSR'd host tag instead of crashing
295
+ * on an undefined `dataset`.
296
+ * @returns {Record<string, string>}
297
+ */
298
+ get dataset() {
299
+ if (this.__dataset) return this.__dataset;
300
+ const el = this;
301
+ const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
302
+ this.__dataset = new Proxy(/** @type {Record<string,string>} */ ({}), {
303
+ get(_t, prop) {
304
+ if (typeof prop !== 'string') return undefined;
305
+ const v = el.getAttribute(toAttr(prop));
306
+ return v === null ? undefined : v;
307
+ },
308
+ set(_t, prop, value) {
309
+ if (typeof prop === 'string') el.setAttribute(toAttr(prop), value);
310
+ return true;
311
+ },
312
+ has(_t, prop) {
313
+ return typeof prop === 'string' && el.hasAttribute(toAttr(prop));
314
+ },
315
+ deleteProperty(_t, prop) {
316
+ if (typeof prop === 'string') el.removeAttribute(toAttr(prop));
317
+ return true;
318
+ },
319
+ ownKeys() {
320
+ return el.getAttributeNames()
321
+ .filter((n) => n.startsWith('data-'))
322
+ .map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase()));
323
+ },
324
+ getOwnPropertyDescriptor(_t, prop) {
325
+ if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) {
326
+ return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) };
327
+ }
328
+ return undefined;
329
+ },
330
+ });
331
+ return this.__dataset;
332
+ }
333
+
334
+ // IDL properties that reflect to a content attribute. A render() that
335
+ // mutates these on the host (a light-DOM compound-component pattern, e.g.
336
+ // `this.className = ...`, `this.hidden = !active`) then surfaces the
337
+ // matching attribute in the SSR'd host tag, matching the browser.
338
+ get className() { return this.getAttribute('class') ?? ''; }
339
+ set className(v) { this.setAttribute('class', v); }
340
+ get hidden() { return this.hasAttribute('hidden'); }
341
+ set hidden(v) { this.toggleAttribute('hidden', !!v); }
342
+ get id() { return this.getAttribute('id') ?? ''; }
343
+ set id(v) { this.setAttribute('id', v); }
344
+ get title() { return this.getAttribute('title') ?? ''; }
345
+ set title(v) { this.setAttribute('title', v); }
346
+ get slot() { return this.getAttribute('slot') ?? ''; }
347
+ set slot(v) { this.setAttribute('slot', v); }
348
+ get role() { return this.getAttribute('role'); }
349
+ set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); }
350
+ get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); }
351
+ set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
352
+
353
+ // No server event loop: listeners never fire at SSR. The no-op keeps a
354
+ // constructor that wires delegated listeners (a common lit pattern) from
355
+ // crashing; the browser re-runs the constructor on hydration where the
356
+ // real HTMLElement methods apply.
357
+ addEventListener() {}
358
+ removeEventListener() {}
359
+ dispatchEvent() { return true; }
360
+
361
+ /** @returns {any} */
362
+ attachInternals() {
363
+ // Match the browser (and lit's shim): a second attach is an error.
364
+ if (this.__internals !== null) {
365
+ throw new Error(
366
+ "Failed to execute 'attachInternals' on 'HTMLElement': " +
367
+ 'ElementInternals for the specified element was already attached.',
368
+ );
369
+ }
370
+ this.__internals = makeServerInternals();
371
+ return this.__internals;
372
+ }
373
+ }
374
+
375
+ // ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed).
376
+ // A render() that sets ARIA state via the IDL properties then surfaces the
377
+ // matching aria-* attribute in the SSR'd host tag, matching the browser. The
378
+ // IDL name maps to the content attribute by lowercasing the part after
379
+ // `aria` and prefixing `aria-` (ariaPressed -> aria-pressed).
380
+ const ARIA_IDL_PROPS = [
381
+ 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount',
382
+ 'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled',
383
+ 'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts',
384
+ 'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine',
385
+ 'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet',
386
+ 'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription',
387
+ 'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize',
388
+ 'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText',
389
+ ];
390
+ for (const idl of ARIA_IDL_PROPS) {
391
+ const attr = 'aria-' + idl.slice(4).toLowerCase();
392
+ Object.defineProperty(ServerElement.prototype, idl, {
393
+ configurable: true,
394
+ get() { return this.getAttribute(attr); },
395
+ set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); },
396
+ });
397
+ }
398
+
399
+ // Base class choice: real HTMLElement on the browser, the shim on the server.
400
+ const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
152
401
 
153
402
  export class WebComponent extends Base {
154
403
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
@@ -467,6 +716,13 @@ export class WebComponent extends Base {
467
716
 
468
717
  _activate() {
469
718
  this._connected = true;
719
+ // Reflect declared reflect:true properties from their current value now
720
+ // that the element is connected. Constructor / willUpdate defaults were
721
+ // set while disconnected (the setter skips reflection then), so this is
722
+ // what makes a freshly-created client element carry the same reflected
723
+ // attributes the SSR walker emitted. Same-value reflects are no-ops, so a
724
+ // hydrated element (whose attribute already arrived from SSR) is unchanged.
725
+ this._reflectDeclaredAttributes();
470
726
  const Ctor = /** @type any */ (this.constructor);
471
727
  if (Ctor.shadow === true) {
472
728
  const hadSSRShadow = !!this.shadowRoot;
@@ -815,6 +1071,67 @@ export class WebComponent extends Base {
815
1071
  }
816
1072
  }
817
1073
 
1074
+ /**
1075
+ * Run the pre-render half of the update cycle synchronously for SSR.
1076
+ *
1077
+ * The SSR walker constructs the instance, applies attributes/props, then
1078
+ * calls this BEFORE `render()`. It mirrors how lit drives the update
1079
+ * lifecycle server-side: `willUpdate` runs so derived state computed there
1080
+ * is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
1081
+ * runs, then reflect:true properties are written to attributes so they
1082
+ * appear in the serialized output. It deliberately does NOT run
1083
+ * `shouldUpdate` (SSR always produces the first paint, there is no prior
1084
+ * render to skip), `update` (the walker commits by calling `render`
1085
+ * itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
1086
+ * DOM work).
1087
+ *
1088
+ * `_isUpdating` is set so a property assignment inside `willUpdate` folds
1089
+ * into the current `changedProperties` snapshot instead of scheduling a
1090
+ * fresh cycle (the SSR scheduler short-circuits anyway, since the element
1091
+ * is not connected). Hook throws propagate to the walker's per-component
1092
+ * try/catch, which logs an actionable hint and skips DSD for that element,
1093
+ * the same as a throwing `render()`.
1094
+ */
1095
+ performServerUpdate() {
1096
+ const changedProperties = this._changedProperties;
1097
+ this._isUpdating = true;
1098
+ try {
1099
+ this.willUpdate(changedProperties);
1100
+ for (const c of this.__controllers) {
1101
+ if (c.hostUpdate) c.hostUpdate();
1102
+ }
1103
+ this._reflectDeclaredAttributes();
1104
+ } finally {
1105
+ this._isUpdating = false;
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Reflect every reflect:true (non-state) property to its attribute from its
1111
+ * CURRENT value, regardless of whether the value arrived via the setter.
1112
+ *
1113
+ * The property setter only reflects on a connected change, so a value set
1114
+ * in the constructor (a default) or in `willUpdate` is not reflected by the
1115
+ * setter alone. This syncs those: the SSR walker calls it in
1116
+ * `performServerUpdate` (writing into the server attribute shim, which the
1117
+ * walker reads back into the rendered HTML), and `_activate` calls it on
1118
+ * the client's first connected render. Running it on both sides is what
1119
+ * keeps a server-rendered element and a freshly-created client element
1120
+ * agree on their reflected attributes (matching lit, which reflects during
1121
+ * the first update). `_reflectAttribute`'s re-entrancy guard makes a
1122
+ * same-value reflect a no-op, so re-reflecting an attribute that already
1123
+ * arrived from SSR does not churn.
1124
+ * @private
1125
+ */
1126
+ _reflectDeclaredAttributes() {
1127
+ const props = /** @type {any} */ (this.constructor).properties || {};
1128
+ for (const name of Object.keys(props)) {
1129
+ const decl = props[name];
1130
+ if (!decl || !decl.reflect || decl.state) continue;
1131
+ this._reflectAttribute(name, this[name], decl);
1132
+ }
1133
+ }
1134
+
818
1135
  // ---------------------------------------------------------------------------
819
1136
  // Lifecycle hooks: override in subclasses
820
1137
  // ---------------------------------------------------------------------------
package/src/directives.js CHANGED
@@ -12,11 +12,17 @@
12
12
  * } from '@webjsdev/core/directives';
13
13
  * ```
14
14
  *
15
- * `repeat()` lives in `./repeat.js` (re-exported from the package root).
15
+ * `repeat()` is implemented in `./repeat.js`; it is re-exported here so the
16
+ * `@webjsdev/core/directives` subpath exposes the whole documented directive
17
+ * table in src/dev mode too (the dist browser bundle already carries it).
16
18
  *
17
19
  * @module directives
18
20
  */
19
21
 
22
+ // Re-export so the `/directives` subpath surface matches the documented table
23
+ // in both src and dist mode. The implementation stays in repeat.js.
24
+ export { repeat, isRepeat } from './repeat.js';
25
+
20
26
  /* ================================================================
21
27
  * unsafeHTML
22
28
  * ================================================================ */
@@ -322,9 +322,13 @@ const SSR_BROWSER_GLOBALS = new Set([
322
322
  'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
323
323
  'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
324
324
  ]);
325
+ // Attribute methods (get/set/has/remove/toggleAttribute), the event methods
326
+ // (add/removeEventListener, dispatchEvent), and attachInternals are backed by
327
+ // the server-side element shim and work at SSR, so they are NOT listed here.
328
+ // What remains is the genuinely browser-only HTMLElement surface that still
329
+ // has no server stand-in and throws at SSR.
325
330
  const SSR_HTMLELEMENT_METHODS = new Set([
326
- 'attachShadow', 'setAttribute', 'getAttribute', 'removeAttribute',
327
- 'hasAttribute', 'dispatchEvent', 'querySelector', 'querySelectorAll',
331
+ 'attachShadow', 'querySelector', 'querySelectorAll',
328
332
  'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
329
333
  ]);
330
334
 
@@ -358,7 +362,7 @@ function browserMemberHint(e) {
358
362
  * @param {SuspenseCtx} [ctx]
359
363
  * @returns {Promise<string>}
360
364
  */
361
- async function injectDSD(html, ctx) {
365
+ async function injectDSD(html, ctx, ancestors = []) {
362
366
  const tags = allTags();
363
367
  if (!tags.length) return html;
364
368
  // Sort longest tag name first so the regex alternation tries the most
@@ -383,10 +387,16 @@ async function injectDSD(html, ctx) {
383
387
  // Track which custom elements actually appeared: used by SSR to emit
384
388
  // `<link rel="modulepreload">` hints for their module URLs.
385
389
  if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
386
- const opening = selfClose ? `<${tag}${attrs}>` : match;
390
+ let opening = selfClose ? `<${tag}${attrs}>` : match;
387
391
  try {
388
392
  const isShadow = /** @type any */ (Cls).shadow === true;
389
393
  const instance = new /** @type any */ (Cls)();
394
+ // Thread the ancestor chain (the enclosing custom-element instances)
395
+ // so the server element shim's closest() can resolve a parent at SSR.
396
+ // Set before performServerUpdate so a willUpdate() that reads a parent
397
+ // via closest() sees the chain. Each child recursion below extends it.
398
+ instance.__ssrTag = tag;
399
+ instance.__ssrAncestors = ancestors;
390
400
  const attrMap = parseAttrs(attrs);
391
401
  // Decode `data-webjs-prop-*` attributes first (rich-typed values
392
402
  // emitted for `.prop=${val}` bindings in the parent template),
@@ -394,10 +404,34 @@ async function injectDSD(html, ctx) {
394
404
  // properties` type. Property bindings take priority on a name
395
405
  // collision because they preserve the original JS reference.
396
406
  const propValues = consumePropAttrs(attrMap);
407
+ // Names already present in the source opening tag (including the
408
+ // data-webjs-prop-* bindings, which were stripped from attrMap above
409
+ // but remain in the emitted `attrs` string). Reflected/added
410
+ // attributes are appended only when their name is NOT already here, so
411
+ // existing output stays byte-identical when nothing reflects.
412
+ const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
413
+ // Seed the server attribute shim so `this.getAttribute(...)` /
414
+ // `this.hasAttribute(...)` in willUpdate / render read the source
415
+ // attributes (a lit muscle-memory pattern) instead of reading empty.
416
+ seedServerAttrs(instance, attrMap);
397
417
  applyAttrsToInstance(instance, attrMap, Cls);
398
418
  for (const [k, v] of Object.entries(propValues)) instance[k] = v;
419
+ // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
420
+ // then reflect reflect:true props) so derived state computed there is
421
+ // correct in the SSR'd HTML, matching how lit runs the update cycle at
422
+ // SSR. WebComponent instances expose performServerUpdate; bare
423
+ // Base-extending kit components (no lifecycle) do not, so it is guarded.
424
+ if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
399
425
  let tpl = instance.render ? instance.render() : '';
400
426
  if (tpl && typeof tpl.then === 'function') tpl = await tpl;
427
+ // Surface attributes the component set up to and including render()
428
+ // that were not already in the source tag: reflected reflect:true
429
+ // props, an explicit this.setAttribute in the constructor / willUpdate,
430
+ // or a host-attribute mutation inside render() itself (a light-DOM
431
+ // compound-component pattern, e.g. this.dataset.state / this.className /
432
+ // this.hidden on the host). Reading after render() captures all three.
433
+ // Appending keeps the original tag byte-identical when nothing changed.
434
+ opening = appendReflectedAttrs(opening, instance, presentAttrNames);
401
435
  // Render the template to HTML. injectDSD recurses on the result so
402
436
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
403
437
  // get their own DSD pass.
@@ -407,7 +441,7 @@ async function injectDSD(html, ctx) {
407
441
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
408
442
  // browser handles projection from the host's light-DOM children
409
443
  // into the shadow tree natively. No framework substitution here.
410
- const innerProcessed = await injectDSD(rawInner, ctx);
444
+ const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
411
445
  const rawStyles = /** @type any */ (Cls).styles;
412
446
  const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
413
447
  const styleStr = stylesToString(styleList);
@@ -470,7 +504,7 @@ async function injectDSD(html, ctx) {
470
504
  }
471
505
  const partitioned = partitionAuthoredBySlot(authoredInner);
472
506
  const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
473
- const innerProcessed = await injectDSD(innerWithSlots, ctx);
507
+ const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
474
508
  edits.push({
475
509
  start: m.index,
476
510
  end: closeEnd,
@@ -498,6 +532,11 @@ async function injectDSD(html, ctx) {
498
532
  // independently against the original html, but those inner elements
499
533
  // are processed by the recursive injectDSD call on innerWithSlots.
500
534
  // Keeping both edits would double-process them and corrupt the output.
535
+ // A consequence: a nested instance's render() runs once per chain depth
536
+ // (the discarded top-level pass sees an empty ancestor chain, so its
537
+ // closest() reads null; the kept recursive pass has the real chain). The
538
+ // kept pass is the only output, and closest() is a read, so render() must
539
+ // stay pure at SSR (the standard SSR contract), not branch on side effects.
501
540
  edits.sort((a, b) => a.start - b.start);
502
541
  /** @type {{start:number, end:number, text:string}[]} */
503
542
  const filtered = [];
@@ -764,6 +803,55 @@ function parseAttrs(attrStr) {
764
803
  return out;
765
804
  }
766
805
 
806
+ /**
807
+ * Seed the element's attributes from the source opening tag so reads like
808
+ * `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
809
+ * render return the real value during SSR. Goes through `setAttribute`, which
810
+ * both the server element shim (Node SSR) and a real `HTMLElement`
811
+ * (renderToString called in a browser, e.g. tests) implement, so the path
812
+ * does not depend on the shim's internal store. A bare Base-extending kit
813
+ * component without `setAttribute` is skipped.
814
+ *
815
+ * @param {any} instance
816
+ * @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
817
+ */
818
+ function seedServerAttrs(instance, attrs) {
819
+ if (!instance || typeof instance.setAttribute !== 'function') return;
820
+ for (const [name, raw] of Object.entries(attrs)) {
821
+ instance.setAttribute(name, unescapeAttr(raw));
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Append attributes the component set before render (reflected reflect:true
827
+ * properties, or an explicit `this.setAttribute` in the constructor /
828
+ * willUpdate) to the element's opening tag, skipping any name already present
829
+ * in the source tag. Reads via the standard `getAttributeNames` /
830
+ * `getAttribute` API so it works whether the instance is the server shim or a
831
+ * real `HTMLElement`. Returns the opening tag unchanged when there is nothing
832
+ * to add, so existing SSR output stays byte-identical when no component
833
+ * reflects, which preserves the elision on-vs-off differential invariant.
834
+ *
835
+ * @param {string} opening the element's opening tag, ending in `>`
836
+ * @param {any} instance
837
+ * @param {Set<string>} presentAttrNames lowercased names already in the source tag
838
+ * @returns {string}
839
+ */
840
+ function appendReflectedAttrs(opening, instance, presentAttrNames) {
841
+ if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
842
+ let extra = '';
843
+ for (const rawName of instance.getAttributeNames()) {
844
+ const name = String(rawName).toLowerCase();
845
+ if (presentAttrNames.has(name)) continue;
846
+ const value = instance.getAttribute(rawName);
847
+ extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
848
+ }
849
+ if (!extra) return opening;
850
+ // Insert before the closing `>` (the opening tag is normalised to end in
851
+ // `>`; a self-closing source tag was already rewritten without the slash).
852
+ return `${opening.slice(0, -1)}${extra}>`;
853
+ }
854
+
767
855
  /**
768
856
  * Coerce attribute strings to typed properties on a component instance
769
857
  * based on its static `properties` declaration.
@@ -780,7 +868,12 @@ function applyAttrsToInstance(instance, attrs, Cls) {
780
868
  if (def.type === Number) instance[propName] = Number(raw);
781
869
  else if (def.type === Boolean) instance[propName] = raw !== 'false';
782
870
  else if (def.type === Object || def.type === Array) {
783
- try { instance[propName] = JSON.parse(raw); } catch { instance[propName] = raw; }
871
+ // `raw` is the entity-encoded attribute text (parseAttrs returns the
872
+ // literal characters between the quotes), so decode the HTML entities
873
+ // before JSON.parse. A JSON attribute carries `&quot;` for every `"`;
874
+ // parsing it raw throws and would silently fall back to the string,
875
+ // leaving an Object/Array prop holding a string at SSR.
876
+ try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
784
877
  } else instance[propName] = raw;
785
878
  }
786
879
  }
package/src/task.js CHANGED
@@ -93,6 +93,16 @@
93
93
  * @readonly
94
94
  * @enum {number}
95
95
  */
96
+ // The SSR walker now runs controllers' hostUpdate during the pre-render pass
97
+ // (issue #217). A Task must NOT invoke its task function server-side (that
98
+ // would fire a fetch during SSR); the browser runs it on hydration. Read live
99
+ // (not cached) so the auto-run gate reflects the current environment: SSR
100
+ // ships the task's INITIAL state, unchanged from before hostUpdate fired at
101
+ // SSR, and the browser runs it on connect.
102
+ function inBrowser() {
103
+ return typeof window !== 'undefined';
104
+ }
105
+
96
106
  export const TaskStatus = /** @type {const} */ ({
97
107
  INITIAL: 0,
98
108
  PENDING: 1,
@@ -326,6 +336,9 @@ export class Task {
326
336
  */
327
337
  hostUpdate() {
328
338
  if (!this._autoRun) return;
339
+ // Never auto-run server-side: SSR ships the INITIAL state and the task
340
+ // function (typically a fetch) runs only in the browser on hydration.
341
+ if (!inBrowser()) return;
329
342
 
330
343
  const nextArgs = this._argsFn();
331
344