@webjsdev/core 0.7.4 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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,143 @@ 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
+ // No server event loop: listeners never fire at SSR. The no-op keeps a
264
+ // constructor that wires delegated listeners (a common lit pattern) from
265
+ // crashing; the browser re-runs the constructor on hydration where the
266
+ // real HTMLElement methods apply.
267
+ addEventListener() {}
268
+ removeEventListener() {}
269
+ dispatchEvent() { return true; }
270
+
271
+ /** @returns {any} */
272
+ attachInternals() {
273
+ // Match the browser (and lit's shim): a second attach is an error.
274
+ if (this.__internals !== null) {
275
+ throw new Error(
276
+ "Failed to execute 'attachInternals' on 'HTMLElement': " +
277
+ 'ElementInternals for the specified element was already attached.',
278
+ );
279
+ }
280
+ this.__internals = makeServerInternals();
281
+ return this.__internals;
282
+ }
283
+ }
284
+
285
+ // Base class choice: real HTMLElement on the browser, the shim on the server.
286
+ const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
152
287
 
153
288
  export class WebComponent extends Base {
154
289
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
@@ -467,6 +602,13 @@ export class WebComponent extends Base {
467
602
 
468
603
  _activate() {
469
604
  this._connected = true;
605
+ // Reflect declared reflect:true properties from their current value now
606
+ // that the element is connected. Constructor / willUpdate defaults were
607
+ // set while disconnected (the setter skips reflection then), so this is
608
+ // what makes a freshly-created client element carry the same reflected
609
+ // attributes the SSR walker emitted. Same-value reflects are no-ops, so a
610
+ // hydrated element (whose attribute already arrived from SSR) is unchanged.
611
+ this._reflectDeclaredAttributes();
470
612
  const Ctor = /** @type any */ (this.constructor);
471
613
  if (Ctor.shadow === true) {
472
614
  const hadSSRShadow = !!this.shadowRoot;
@@ -815,6 +957,67 @@ export class WebComponent extends Base {
815
957
  }
816
958
  }
817
959
 
960
+ /**
961
+ * Run the pre-render half of the update cycle synchronously for SSR.
962
+ *
963
+ * The SSR walker constructs the instance, applies attributes/props, then
964
+ * calls this BEFORE `render()`. It mirrors how lit drives the update
965
+ * lifecycle server-side: `willUpdate` runs so derived state computed there
966
+ * is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
967
+ * runs, then reflect:true properties are written to attributes so they
968
+ * appear in the serialized output. It deliberately does NOT run
969
+ * `shouldUpdate` (SSR always produces the first paint, there is no prior
970
+ * render to skip), `update` (the walker commits by calling `render`
971
+ * itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
972
+ * DOM work).
973
+ *
974
+ * `_isUpdating` is set so a property assignment inside `willUpdate` folds
975
+ * into the current `changedProperties` snapshot instead of scheduling a
976
+ * fresh cycle (the SSR scheduler short-circuits anyway, since the element
977
+ * is not connected). Hook throws propagate to the walker's per-component
978
+ * try/catch, which logs an actionable hint and skips DSD for that element,
979
+ * the same as a throwing `render()`.
980
+ */
981
+ performServerUpdate() {
982
+ const changedProperties = this._changedProperties;
983
+ this._isUpdating = true;
984
+ try {
985
+ this.willUpdate(changedProperties);
986
+ for (const c of this.__controllers) {
987
+ if (c.hostUpdate) c.hostUpdate();
988
+ }
989
+ this._reflectDeclaredAttributes();
990
+ } finally {
991
+ this._isUpdating = false;
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Reflect every reflect:true (non-state) property to its attribute from its
997
+ * CURRENT value, regardless of whether the value arrived via the setter.
998
+ *
999
+ * The property setter only reflects on a connected change, so a value set
1000
+ * in the constructor (a default) or in `willUpdate` is not reflected by the
1001
+ * setter alone. This syncs those: the SSR walker calls it in
1002
+ * `performServerUpdate` (writing into the server attribute shim, which the
1003
+ * walker reads back into the rendered HTML), and `_activate` calls it on
1004
+ * the client's first connected render. Running it on both sides is what
1005
+ * keeps a server-rendered element and a freshly-created client element
1006
+ * agree on their reflected attributes (matching lit, which reflects during
1007
+ * the first update). `_reflectAttribute`'s re-entrancy guard makes a
1008
+ * same-value reflect a no-op, so re-reflecting an attribute that already
1009
+ * arrived from SSR does not churn.
1010
+ * @private
1011
+ */
1012
+ _reflectDeclaredAttributes() {
1013
+ const props = /** @type {any} */ (this.constructor).properties || {};
1014
+ for (const name of Object.keys(props)) {
1015
+ const decl = props[name];
1016
+ if (!decl || !decl.reflect || decl.state) continue;
1017
+ this._reflectAttribute(name, this[name], decl);
1018
+ }
1019
+ }
1020
+
818
1021
  // ---------------------------------------------------------------------------
819
1022
  // Lifecycle hooks: override in subclasses
820
1023
  // ---------------------------------------------------------------------------
@@ -314,6 +314,45 @@ async function renderTemplate(tr, ctx) {
314
314
  return out;
315
315
  }
316
316
 
317
+ // Browser-only names whose absence during SSR produces a recognisable error.
318
+ // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
319
+ // these at edit time; this turns the runtime SSR crash into the same guidance.
320
+ const SSR_BROWSER_GLOBALS = new Set([
321
+ 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
322
+ 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
323
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
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.
330
+ const SSR_HTMLELEMENT_METHODS = new Set([
331
+ 'attachShadow', 'querySelector', 'querySelectorAll',
332
+ 'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
333
+ ]);
334
+
335
+ /**
336
+ * If `e` is the recognisable failure of touching a browser-only API during
337
+ * SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
338
+ * HTMLElement method that does not exist on the bare server-side instance),
339
+ * return an actionable, member-naming hint; otherwise null.
340
+ * @param {unknown} e
341
+ * @returns {string | null}
342
+ */
343
+ function browserMemberHint(e) {
344
+ const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
345
+ let m = /^(\w+) is not defined$/.exec(msg);
346
+ if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
347
+ return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
348
+ }
349
+ m = /\.(\w+) is not a function$/.exec(msg);
350
+ if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
351
+ return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
352
+ }
353
+ return null;
354
+ }
355
+
317
356
  /**
318
357
  * Scan an HTML string for registered custom elements and inject
319
358
  * Declarative Shadow DOM (`<template shadowrootmode="open">`).
@@ -348,7 +387,7 @@ async function injectDSD(html, ctx) {
348
387
  // Track which custom elements actually appeared: used by SSR to emit
349
388
  // `<link rel="modulepreload">` hints for their module URLs.
350
389
  if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
351
- const opening = selfClose ? `<${tag}${attrs}>` : match;
390
+ let opening = selfClose ? `<${tag}${attrs}>` : match;
352
391
  try {
353
392
  const isShadow = /** @type any */ (Cls).shadow === true;
354
393
  const instance = new /** @type any */ (Cls)();
@@ -359,8 +398,29 @@ async function injectDSD(html, ctx) {
359
398
  // properties` type. Property bindings take priority on a name
360
399
  // collision because they preserve the original JS reference.
361
400
  const propValues = consumePropAttrs(attrMap);
401
+ // Names already present in the source opening tag (including the
402
+ // data-webjs-prop-* bindings, which were stripped from attrMap above
403
+ // but remain in the emitted `attrs` string). Reflected/added
404
+ // attributes are appended only when their name is NOT already here, so
405
+ // existing output stays byte-identical when nothing reflects.
406
+ const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
407
+ // Seed the server attribute shim so `this.getAttribute(...)` /
408
+ // `this.hasAttribute(...)` in willUpdate / render read the source
409
+ // attributes (a lit muscle-memory pattern) instead of reading empty.
410
+ seedServerAttrs(instance, attrMap);
362
411
  applyAttrsToInstance(instance, attrMap, Cls);
363
412
  for (const [k, v] of Object.entries(propValues)) instance[k] = v;
413
+ // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
414
+ // then reflect reflect:true props) so derived state computed there is
415
+ // correct in the SSR'd HTML, matching how lit runs the update cycle at
416
+ // SSR. WebComponent instances expose performServerUpdate; bare
417
+ // Base-extending kit components (no lifecycle) do not, so it is guarded.
418
+ if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
419
+ // Surface attributes the component set before render (reflected
420
+ // reflect:true props, or an explicit this.setAttribute in the
421
+ // constructor / willUpdate) that were not already in the source tag.
422
+ // Appending keeps the original tag byte-identical when nothing changed.
423
+ opening = appendReflectedAttrs(opening, instance, presentAttrNames);
364
424
  let tpl = instance.render ? instance.render() : '';
365
425
  if (tpl && typeof tpl.then === 'function') tpl = await tpl;
366
426
  // Render the template to HTML. injectDSD recurses on the result so
@@ -443,7 +503,15 @@ async function injectDSD(html, ctx) {
443
503
  });
444
504
  }
445
505
  } catch (e) {
446
- console.error(`[webjs] SSR failed for <${tag}>:`, e);
506
+ const hint = browserMemberHint(e);
507
+ if (hint) {
508
+ console.error(
509
+ `[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
510
+ e,
511
+ );
512
+ } else {
513
+ console.error(`[webjs] SSR failed for <${tag}>:`, e);
514
+ }
447
515
  }
448
516
  }
449
517
  if (!edits.length) return html;
@@ -721,6 +789,55 @@ function parseAttrs(attrStr) {
721
789
  return out;
722
790
  }
723
791
 
792
+ /**
793
+ * Seed the element's attributes from the source opening tag so reads like
794
+ * `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
795
+ * render return the real value during SSR. Goes through `setAttribute`, which
796
+ * both the server element shim (Node SSR) and a real `HTMLElement`
797
+ * (renderToString called in a browser, e.g. tests) implement, so the path
798
+ * does not depend on the shim's internal store. A bare Base-extending kit
799
+ * component without `setAttribute` is skipped.
800
+ *
801
+ * @param {any} instance
802
+ * @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
803
+ */
804
+ function seedServerAttrs(instance, attrs) {
805
+ if (!instance || typeof instance.setAttribute !== 'function') return;
806
+ for (const [name, raw] of Object.entries(attrs)) {
807
+ instance.setAttribute(name, unescapeAttr(raw));
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Append attributes the component set before render (reflected reflect:true
813
+ * properties, or an explicit `this.setAttribute` in the constructor /
814
+ * willUpdate) to the element's opening tag, skipping any name already present
815
+ * in the source tag. Reads via the standard `getAttributeNames` /
816
+ * `getAttribute` API so it works whether the instance is the server shim or a
817
+ * real `HTMLElement`. Returns the opening tag unchanged when there is nothing
818
+ * to add, so existing SSR output stays byte-identical when no component
819
+ * reflects, which preserves the elision on-vs-off differential invariant.
820
+ *
821
+ * @param {string} opening the element's opening tag, ending in `>`
822
+ * @param {any} instance
823
+ * @param {Set<string>} presentAttrNames lowercased names already in the source tag
824
+ * @returns {string}
825
+ */
826
+ function appendReflectedAttrs(opening, instance, presentAttrNames) {
827
+ if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
828
+ let extra = '';
829
+ for (const rawName of instance.getAttributeNames()) {
830
+ const name = String(rawName).toLowerCase();
831
+ if (presentAttrNames.has(name)) continue;
832
+ const value = instance.getAttribute(rawName);
833
+ extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
834
+ }
835
+ if (!extra) return opening;
836
+ // Insert before the closing `>` (the opening tag is normalised to end in
837
+ // `>`; a self-closing source tag was already rewritten without the slash).
838
+ return `${opening.slice(0, -1)}${extra}>`;
839
+ }
840
+
724
841
  /**
725
842
  * Coerce attribute strings to typed properties on a component instance
726
843
  * based on its static `properties` declaration.
@@ -737,7 +854,12 @@ function applyAttrsToInstance(instance, attrs, Cls) {
737
854
  if (def.type === Number) instance[propName] = Number(raw);
738
855
  else if (def.type === Boolean) instance[propName] = raw !== 'false';
739
856
  else if (def.type === Object || def.type === Array) {
740
- try { instance[propName] = JSON.parse(raw); } catch { instance[propName] = raw; }
857
+ // `raw` is the entity-encoded attribute text (parseAttrs returns the
858
+ // literal characters between the quotes), so decode the HTML entities
859
+ // before JSON.parse. A JSON attribute carries `&quot;` for every `"`;
860
+ // parsing it raw throws and would silently fall back to the string,
861
+ // leaving an Object/Array prop holding a string at SSR.
862
+ try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
741
863
  } else instance[propName] = raw;
742
864
  }
743
865
  }
@@ -1003,7 +1003,17 @@ function drainPrefetchQueue() {
1003
1003
  }
1004
1004
 
1005
1005
  /**
1006
- * Store a speculative entry under LRU + cap.
1006
+ * Store a speculative entry under LRU + cap, then announce that the
1007
+ * fragment is cached and consumable.
1008
+ *
1009
+ * The `webjs:prefetch` event fires the instant a speculative fragment
1010
+ * becomes consumable (after the response body has been read), which is
1011
+ * strictly later than the prefetch request going out. App code can
1012
+ * listen to instrument prefetch hit rate, and tests can await it to know
1013
+ * a subsequent click will consume the cache rather than refetch. The
1014
+ * detail carries the cached URL and a `from: 'prefetch'` tag so a single
1015
+ * listener can disambiguate it from `webjs:navigate`.
1016
+ *
1007
1017
  * @param {string} key
1008
1018
  * @param {PrefetchEntry} entry
1009
1019
  */
@@ -1014,6 +1024,11 @@ function prefetchStore(key, entry) {
1014
1024
  const oldest = prefetchCache.keys().next().value;
1015
1025
  prefetchCache.delete(oldest);
1016
1026
  }
1027
+ if (typeof document !== 'undefined') {
1028
+ document.dispatchEvent(new CustomEvent('webjs:prefetch', {
1029
+ detail: { url: entry.finalUrl, key, from: 'prefetch' },
1030
+ }));
1031
+ }
1017
1032
  }
1018
1033
 
1019
1034
  /**
@@ -1267,7 +1282,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1267
1282
  }
1268
1283
  }
1269
1284
 
1270
- document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId } }));
1285
+ document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
1271
1286
  }
1272
1287
 
1273
1288
  /**
package/src/serialize.js CHANGED
@@ -321,15 +321,26 @@ function decode(v, ctx) {
321
321
 
322
322
  function decodeTagged(v, tag, ctx) {
323
323
  const id = v[ID_KEY];
324
+ // Register the decoded value under its `_id` so a later `Ref` to it
325
+ // resolves. Container tags (Map/Set/Arr/Error/object) register BEFORE
326
+ // decoding children (so a cycle through the container resolves); leaf
327
+ // rich types (Date, typed arrays, Blob, ...) have no children and register
328
+ // through this helper at return. Without it, a SHARED Date or typed array
329
+ // (the same instance appearing twice) decodes the first copy but throws
330
+ // "Dangling reference" on the second.
331
+ const reg = (value) => {
332
+ if (typeof id === 'number') ctx.refs.set(id, value);
333
+ return value;
334
+ };
324
335
  switch (tag) {
325
336
  case 'undef': return undefined;
326
337
  case 'NaN': return NaN;
327
338
  case 'Inf': return Infinity;
328
339
  case '-Inf': return -Infinity;
329
340
  case '-0': return -0;
330
- case 'BigInt': return BigInt(v.v);
331
- case 'Sym': return Symbol.for(v.v);
332
- case 'Date': return v.v == null ? new Date(NaN) : new Date(v.v);
341
+ case 'BigInt': return reg(BigInt(v.v));
342
+ case 'Sym': return reg(Symbol.for(v.v));
343
+ case 'Date': return reg(v.v == null ? new Date(NaN) : new Date(v.v));
333
344
  case 'Error': {
334
345
  const e = new Error(v.v.message);
335
346
  e.name = v.v.name;
@@ -365,16 +376,17 @@ function decodeTagged(v, tag, ctx) {
365
376
  case 'Blob': {
366
377
  if (typeof Blob === 'undefined') throw new TypeError('Blob is not available in this environment.');
367
378
  const bytes = b64ToBytes(v.v);
368
- return new Blob([bytes], { type: v.t || '' });
379
+ return reg(new Blob([bytes], { type: v.t || '' }));
369
380
  }
370
381
  case 'File': {
371
382
  if (typeof File === 'undefined') throw new TypeError('File is not available in this environment.');
372
383
  const bytes = b64ToBytes(v.v);
373
- return new File([bytes], v.n, { type: v.t || '', lastModified: v.m });
384
+ return reg(new File([bytes], v.n, { type: v.t || '', lastModified: v.m }));
374
385
  }
375
386
  case 'FD': {
376
387
  if (typeof FormData === 'undefined') throw new TypeError('FormData is not available in this environment.');
377
388
  const fd = new FormData();
389
+ reg(fd);
378
390
  for (const [k, val] of v.v) {
379
391
  const decoded = decode(val, ctx);
380
392
  fd.append(k, decoded);
@@ -385,17 +397,17 @@ function decodeTagged(v, tag, ctx) {
385
397
  // Typed arrays + binary buffers
386
398
  if (tag === 'buf') {
387
399
  const bytes = b64ToBytes(v.v);
388
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
400
+ return reg(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
389
401
  }
390
402
  if (tag === 'dv') {
391
403
  const bytes = b64ToBytes(v.v);
392
- return new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
404
+ return reg(new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)));
393
405
  }
394
406
  const Ctor = TAG_TO_TYPED_ARRAY[tag];
395
407
  if (Ctor) {
396
408
  const bytes = b64ToBytes(v.v);
397
409
  const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
398
- return new Ctor(sliced);
410
+ return reg(new Ctor(sliced));
399
411
  }
400
412
  throw new TypeError(`Unknown serialization tag: ${tag}`);
401
413
  }
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