@opentf/web 0.6.0 → 0.8.0

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/core/signals.js CHANGED
@@ -15,6 +15,8 @@ import { reportError } from "./errors.js";
15
15
 
16
16
  /** The consumer (effect or computed) currently executing, if any. */
17
17
  let activeConsumer = null;
18
+ /** Disposer collector of the innermost `scope()` currently building, if any. */
19
+ let activeScope = null;
18
20
  /** Depth of nested `batch()` calls; effects flush when this returns to 0. */
19
21
  let batchDepth = 0;
20
22
  /** Re-entrancy guard for the flush loop. */
@@ -149,6 +151,36 @@ export function computed(fn) {
149
151
  };
150
152
  }
151
153
 
154
+ /**
155
+ * Collect ownership of the effects created while `fn` runs. Returns
156
+ * `{ result, dispose }`: `dispose()` stops every effect `fn` created (including
157
+ * ones created in nested calls, unless an inner `scope()` claimed them first).
158
+ *
159
+ * This is how dynamic regions own their bindings: a keyed-list item, a
160
+ * conditional branch, or a mounted page builds its DOM inside a scope, and
161
+ * evicting/swapping/unmounting disposes the scope — otherwise the bindings'
162
+ * effects would stay subscribed to their signals forever (a leak that also
163
+ * makes every later write pay for dead subscribers).
164
+ */
165
+ export function scope(fn) {
166
+ const disposers = [];
167
+ const prev = activeScope;
168
+ activeScope = disposers;
169
+ let result;
170
+ try {
171
+ result = fn();
172
+ } finally {
173
+ activeScope = prev;
174
+ }
175
+ return {
176
+ result,
177
+ dispose() {
178
+ for (const d of disposers) d();
179
+ disposers.length = 0;
180
+ },
181
+ };
182
+ }
183
+
152
184
  /**
153
185
  * Run `fn` immediately, tracking the signals it reads, and re-run it whenever
154
186
  * any of them change. `fn` may return a cleanup function, which runs before the
@@ -166,22 +198,31 @@ export function effect(fn) {
166
198
  runCleanup(node);
167
199
  clearSources(node);
168
200
  const prev = activeConsumer;
201
+ const prevScope = activeScope;
202
+ // Effects created during a run belong to whoever the run's body says they
203
+ // do (an explicit inner scope()), never to whatever scope happens to be
204
+ // ambient — a flush-time re-run must not donate its children to an
205
+ // unrelated scope that was open when the write occurred.
169
206
  activeConsumer = node;
207
+ activeScope = null;
170
208
  try {
171
209
  const result = node.fn();
172
210
  if (typeof result === "function") node.cleanup = result;
173
211
  } finally {
174
212
  activeConsumer = prev;
213
+ activeScope = prevScope;
175
214
  }
176
215
  },
177
216
  };
178
217
  node.run();
179
- return function dispose() {
218
+ const dispose = function dispose() {
180
219
  if (node.disposed) return;
181
220
  node.disposed = true;
182
221
  runCleanup(node);
183
222
  clearSources(node);
184
223
  };
224
+ if (activeScope) activeScope.push(dispose);
225
+ return dispose;
185
226
  }
186
227
 
187
228
  function runCleanup(node) {
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@opentf/web",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The native-first OTF Web runtime — signal-based reactivity and zero-VDOM DOM operations, paired with the IR-based compiler.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
8
8
  "./signals": "./core/signals.js",
9
9
  "./runtime": "./runtime/index.js",
10
- "./server": "./server/index.js"
10
+ "./server": {
11
+ "types": "./server/index.d.ts",
12
+ "default": "./server/index.js"
13
+ },
14
+ "./server/adapters/node": {
15
+ "types": "./server/adapters/node.d.ts",
16
+ "default": "./server/adapters/node.js"
17
+ }
11
18
  },
12
19
  "files": [
13
20
  "index.js",
package/runtime/dom.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // also back runtime-driven updates (reactive text/attributes). No virtual DOM,
3
3
  // no diffing — just direct, fine-grained DOM writes wired to signals.
4
4
 
5
- import { effect, signal } from "../core/signals.js";
5
+ import { effect, scope, signal } from "../core/signals.js";
6
+ import { claimRegionEnd, claimRegionStart } from "./hydrate.js";
6
7
 
7
8
  // Attribute names that must be assigned as JS properties (not setAttribute) for
8
9
  // correct behavior. Mirrors the set the old runtime relied on (core/constants).
@@ -253,16 +254,62 @@ function toNodes(value) {
253
254
  * Returns the effect disposer.
254
255
  */
255
256
  export function bindChild(anchor, fn) {
256
- let current = [];
257
- return effect(() => {
258
- const next = toNodes(fn());
257
+ return childEffect(anchor, fn, [], false);
258
+ }
259
+
260
+ /**
261
+ * Hydrate a server-rendered conditional / dynamic-node region (docs/HYDRATION.md §3.1,
262
+ * 2.1b). The cursor `cur` is at the `<!--[-->` marker; between it and `<!--]-->` sits the
263
+ * one branch the server rendered (or nothing). `adoptFn(cur)` evaluates the same branch
264
+ * expression with *adopt* calls — only the taken branch's builder runs, claiming its nodes
265
+ * off the shared cursor — so the adopted nodes become the region's initial content with no
266
+ * rebuild. `buildFn()` is the CSR version (build calls) the effect swaps to when a later
267
+ * reactive change selects a different branch. The closing `<!--]-->` is the swap anchor.
268
+ * Returns the effect disposer.
269
+ */
270
+ export function hydrateChild(cur, adoptFn, buildFn) {
271
+ claimRegionStart(cur);
272
+ // Adopt inside a scope so the adopted branch owns its bindings' effects; the
273
+ // region disposes it when a reactive change swaps the branch out.
274
+ const adoptedScope = scope(() => toNodes(adoptFn(cur)));
275
+ const anchor = claimRegionEnd(cur);
276
+ // Seed with the adopted nodes and skip the first effect run's DOM write: the branch is
277
+ // already in place. The first run still evaluates `buildFn` to subscribe to its reactive
278
+ // deps (the built nodes are discarded); from the second run on it swaps normally.
279
+ return childEffect(anchor, buildFn, adoptedScope.result, true, adoptedScope);
280
+ }
281
+
282
+ /** The child-region effect shared by {@link bindChild} (empty seed, no skip) and
283
+ * {@link hydrateChild} (seeded with adopted nodes, first DOM write skipped). On each run
284
+ * the previous nodes are replaced with the new ones, inserted before `anchor`; the
285
+ * previous branch's scope is disposed so its bindings' effects don't outlive it. */
286
+ function childEffect(anchor, fn, current, skipFirst, currentScope = null) {
287
+ let first = skipFirst;
288
+ const disposeEffect = effect(() => {
289
+ // Build in a scope: the branch's nested bindings (text/attr/list effects)
290
+ // belong to this run and are disposed when the branch is replaced.
291
+ const built = scope(() => toNodes(fn()));
292
+ if (first) {
293
+ first = false;
294
+ // Hydration first run subscribes the region effect only; the discarded
295
+ // build's own effects must not stay live alongside the adopted branch.
296
+ built.dispose();
297
+ return; // keep the adopted DOM on first paint
298
+ }
299
+ const next = built.result;
259
300
  const host = anchor.parentNode;
260
301
  for (const n of current) {
261
302
  if (n.parentNode === host) host.removeChild(n);
262
303
  }
263
304
  if (host) for (const n of next) host.insertBefore(n, anchor);
305
+ if (currentScope) currentScope.dispose();
306
+ currentScope = built;
264
307
  current = next;
265
308
  });
309
+ return () => {
310
+ disposeEffect();
311
+ if (currentScope) currentScope.dispose();
312
+ };
266
313
  }
267
314
 
268
315
  /**
@@ -279,10 +326,48 @@ export function bindChild(anchor, fn) {
279
326
  export function bindList(parent, sourceFn, renderItem, keyFn) {
280
327
  const anchor = document.createComment("");
281
328
  parent.appendChild(anchor);
282
- let cache = new Map(); // key -> { sig, node }
283
- let prevKeys = []; // keys in current DOM order, for minimal-move reconciliation
329
+ return reconcileList(anchor, sourceFn, renderItem, keyFn, new Map(), []);
330
+ }
284
331
 
285
- return effect(() => {
332
+ /**
333
+ * Hydrate a server-rendered list region (docs/HYDRATION.md §3.1/2.1). The cursor `cur` is
334
+ * positioned at the `<!--[-->` marker; the region holds one root node per item, then
335
+ * `<!--]-->`. Adopts each item's server node via `adoptItem(cur, itemSignal, index)`
336
+ * (claiming off the shared cursor, which advances to the next item), seeds the reconcile
337
+ * cache with the adopted `{sig, node}` pairs, then wires the *same* keyed-reconcile effect
338
+ * `bindList` uses — so a later data change builds/moves/removes with no first-paint flash.
339
+ * The closing `<!--]-->` becomes the reconcile anchor. Returns the effect disposer.
340
+ */
341
+ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
342
+ claimRegionStart(cur);
343
+ const data = sourceFn();
344
+ const items = Array.isArray(data) ? data : [];
345
+ const cache = new Map();
346
+ const prevKeys = [];
347
+ for (let index = 0; index < items.length; index++) {
348
+ const item = items[index];
349
+ const key = keyFn ? keyFn(item, index) : index;
350
+ const sig = signal(item);
351
+ // Adopt inside a scope so the item owns its bindings' effects; eviction
352
+ // disposes them (same ownership as CSR-built items in reconcileList).
353
+ const s = scope(() => adoptItem(cur, sig, index)); // claims one item subtree off `cur`
354
+ cache.set(key, { sig, node: s.result, dispose: s.dispose });
355
+ prevKeys.push(key);
356
+ }
357
+ const anchor = claimRegionEnd(cur);
358
+ // The seeded effect's first run re-reads the same data: every key hits the cache, so it
359
+ // subscribes for future updates without mutating the already-correct adopted DOM.
360
+ return reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys);
361
+ }
362
+
363
+ /**
364
+ * The keyed-reconcile effect shared by {@link bindList} (empty seed) and
365
+ * {@link hydrateList} (seeded from adopted nodes). `anchor` is the trailing comment new
366
+ * items are inserted before; `cache` (key → `{sig, node}`) and `prevKeys` (keys in DOM
367
+ * order) carry reconciliation state across runs. Returns the effect disposer.
368
+ */
369
+ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
370
+ const disposeEffect = effect(() => {
286
371
  const data = sourceFn();
287
372
  const items = Array.isArray(data) ? data : [];
288
373
  const next = new Map();
@@ -305,7 +390,12 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
305
390
  prevIndex[index] = prevPos.has(key) ? prevPos.get(key) : -1;
306
391
  } else {
307
392
  const sig = signal(item);
308
- entry = { sig, node: renderItem(sig, index) };
393
+ // Build inside a scope: the item owns its bindings' effects, so
394
+ // evicting it detaches them from shared signals (e.g. a selection
395
+ // signal every row's class reads). Without this every discarded row
396
+ // leaves a zombie effect that all later writes keep re-running.
397
+ const s = scope(() => renderItem(sig, index));
398
+ entry = { sig, node: s.result, dispose: s.dispose };
309
399
  prevIndex[index] = -1;
310
400
  }
311
401
  next.set(key, entry);
@@ -313,8 +403,9 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
313
403
  nodes[index] = entry.node;
314
404
  }
315
405
 
316
- // Remove nodes whose keys disappeared.
406
+ // Remove nodes whose keys disappeared, and dispose their bindings.
317
407
  for (const entry of cache.values()) {
408
+ if (entry.dispose) entry.dispose();
318
409
  if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
319
410
  }
320
411
  cache = next;
@@ -327,13 +418,21 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
327
418
  // cascade into. Walk back to front so each insertion's reference node is
328
419
  // already in its final position.
329
420
  const keep = longestIncreasingRun(prevIndex);
330
- const host = anchor.parentNode || parent;
421
+ const host = anchor.parentNode;
422
+ if (!host) return; // anchor detached (list torn down) — nothing to place
331
423
  for (let i = nodes.length - 1; i >= 0; i--) {
332
424
  if (keep.has(i)) continue;
333
425
  const ref = i + 1 < nodes.length ? nodes[i + 1] : anchor;
334
426
  if (nodes[i].nextSibling !== ref) host.insertBefore(nodes[i], ref);
335
427
  }
336
428
  });
429
+ return () => {
430
+ disposeEffect();
431
+ for (const entry of cache.values()) {
432
+ if (entry.dispose) entry.dispose();
433
+ }
434
+ cache.clear();
435
+ };
337
436
  }
338
437
 
339
438
  /**
@@ -19,6 +19,21 @@
19
19
  export const HOLE_START = "$";
20
20
  export const HOLE_END = "/";
21
21
 
22
+ /** Comment markers bounding a variable structural region — a list or a conditional —
23
+ * in server HTML (docs/HYDRATION.md §3.1): `<!--[-->` opens, `<!--]-->` closes. A list
24
+ * holds one item root node per item; a conditional holds the currently rendered branch
25
+ * (or nothing). The closing marker becomes the reconcile/swap anchor on hydration. */
26
+ export const REGION_START = "[";
27
+ export const REGION_END = "]";
28
+
29
+ /** Markers bounding a *component's* light-DOM `{children}` slot (docs/HYDRATION.md §3.1,
30
+ * 2.1d) — distinct bytes from the list/conditional region markers so a component's own
31
+ * internal regions never collide with its slot. The slot's content is the *parent's* JSX
32
+ * (server-rendered here), so the parent finds this slot by its `<!--c[-->` marker within the
33
+ * host and adopts the children (its reactivity), while the component steps over the slot. */
34
+ export const SLOT_START = "c[";
35
+ export const SLOT_END = "c]";
36
+
22
37
  const ELEMENT = 1;
23
38
  const TEXT = 3;
24
39
  const COMMENT = 8;
@@ -37,6 +52,12 @@ export class HydrationMismatch extends Error {
37
52
  // True during the initial hydration pass. A component's connectedCallback reads it
38
53
  // to decide whether to *adopt* its server children or *build* a fresh subtree — the
39
54
  // custom-element-as-island coordination (docs/HYDRATION.md §3.4).
55
+ //
56
+ // Why a flag and not the per-instance `this.firstChild` test: a server-rendered host
57
+ // and a client-`createElement`'d host that was handed call-site children both have a
58
+ // `firstChild`, so the structural test alone can't tell "adopt the server DOM" from
59
+ // "build and slot these children" (it mis-adopts on plain SPA navigation). The flag is
60
+ // the unambiguous signal — it is only ever set during the one-shot first-paint pass.
40
61
  let _hydrating = false;
41
62
 
42
63
  /** Is the client mid-hydration right now? */
@@ -44,7 +65,9 @@ export function isHydrating() {
44
65
  return _hydrating;
45
66
  }
46
67
 
47
- /** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe). */
68
+ /** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe).
69
+ * Synchronous only — for the async first-paint pass use {@link beginHydration} /
70
+ * {@link endHydration}, which span the route module's `import()`. */
48
71
  export function runHydration(fn) {
49
72
  const prev = _hydrating;
50
73
  _hydrating = true;
@@ -55,6 +78,65 @@ export function runHydration(fn) {
55
78
  }
56
79
  }
57
80
 
81
+ // Route modules are code-split (`() => import(...)`), so a route's custom elements are
82
+ // defined — and its server-rendered hosts upgrade synchronously — *during* the router's
83
+ // `await import()`, before the page `hydrate` factory runs. The flag therefore has to be
84
+ // set across that whole async region, which a synchronous `runHydration` can't do; the
85
+ // router brackets first paint with these instead. First-paint hydration is a single,
86
+ // sequential boot step (no concurrent navigation), so a plain module-global set is safe.
87
+
88
+ /** Begin the first-paint hydration pass; pair with {@link endHydration}. Set *before*
89
+ * the route module is imported so every server host upgrading at `customElements.define`
90
+ * observes it and adopts (docs/HYDRATION.md §3.4). */
91
+ export function beginHydration() {
92
+ _hydrating = true;
93
+ }
94
+
95
+ /** End the first-paint hydration pass — subsequent client navigations build fresh. */
96
+ export function endHydration() {
97
+ _hydrating = false;
98
+ }
99
+
100
+ // ── the serialized props payload (compiler-driven data hydration) ─────────────
101
+ // SSG/SSR embeds each island's rich props in a `<script type="application/json"
102
+ // id="__otfw_h">`, keyed by the host's `data-h` id (see server/ssg-runtime.js). A
103
+ // hydrating component reads its props from here — real JS values (objects, arrays,
104
+ // numbers), not lossy string attributes — so it resumes with correct data, with no flash
105
+ // and no dependence on a parent walk re-applying props. Read once and cached.
106
+ let _payload; // undefined = not yet read; null = none present; else the parsed array
107
+
108
+ function hydrationPayload() {
109
+ if (_payload === undefined) {
110
+ const el = typeof document !== "undefined" ? document.getElementById("__otfw_h") : null;
111
+ try {
112
+ _payload = el ? JSON.parse(el.textContent || "[]") : null;
113
+ } catch {
114
+ _payload = null;
115
+ }
116
+ }
117
+ return _payload;
118
+ }
119
+
120
+ /**
121
+ * The rich hydration props recorded for host `el` (keyed by its `data-h` id), or `null`
122
+ * when there is no payload for it — a client-`createElement`'d element on SPA navigation,
123
+ * or a plain CSR build. A hydrate-target component constructor reads this to initialize
124
+ * its prop signals, falling back to attributes/defaults when it returns `null`.
125
+ */
126
+ export function hydrationProps(el) {
127
+ const data = hydrationPayload();
128
+ if (!data || !el || typeof el.getAttribute !== "function") return null;
129
+ const id = el.getAttribute("data-h");
130
+ if (id == null) return null;
131
+ const entry = data[+id];
132
+ return entry == null ? null : entry;
133
+ }
134
+
135
+ /** Reset the cached payload (tests only — a fresh document between cases). */
136
+ export function __resetHydrationPayload() {
137
+ _payload = undefined;
138
+ }
139
+
58
140
  // ── the cursor ────────────────────────────────────────────────────────────────
59
141
 
60
142
  /** A walker over `parent`'s children, positioned at the next node to claim. */
@@ -63,10 +145,16 @@ export function cursor(parent) {
63
145
  }
64
146
 
65
147
  /** Advance past the next node without claiming it (a static text node between
66
- * dynamic siblings), returning it. */
148
+ * dynamic siblings), returning it. The template only emits a skip for a static
149
+ * `ViewNode::Text`, which the server renders as a real text node; assert that so a
150
+ * cursor misalignment surfaces *here* (with a clear message) instead of downstream at
151
+ * an unrelated claim. Throws {@link HydrationMismatch} on a wrong/absent node. */
67
152
  export function skipNode(cur) {
68
153
  const n = cur.node;
69
- cur.node = n ? n.nextSibling : null;
154
+ if (!n || n.nodeType !== TEXT) {
155
+ throw new HydrationMismatch(`expected static text, found ${describe(n)}`);
156
+ }
157
+ cur.node = n.nextSibling;
70
158
  return n;
71
159
  }
72
160
 
@@ -111,6 +199,65 @@ export function claimText(cur) {
111
199
  return textNode;
112
200
  }
113
201
 
202
+ /** Claim the `<!--[-->` marker that opens a server-rendered region (list or conditional),
203
+ * advancing the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
204
+ export function claimRegionStart(cur) {
205
+ const n = cur.node;
206
+ if (!n || n.nodeType !== COMMENT || n.data !== REGION_START) {
207
+ throw new HydrationMismatch(`expected a region start marker, found ${describe(n)}`);
208
+ }
209
+ cur.node = n.nextSibling;
210
+ return n;
211
+ }
212
+
213
+ /** Claim the `<!--]-->` marker that closes a region and return it — it becomes the
214
+ * reconcile/swap anchor (`hydrateList`/`hydrateChild` insert later nodes before it).
215
+ * Advances the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
216
+ export function claimRegionEnd(cur) {
217
+ const n = cur.node;
218
+ if (!n || n.nodeType !== COMMENT || n.data !== REGION_END) {
219
+ throw new HydrationMismatch(`expected a region end marker, found ${describe(n)}`);
220
+ }
221
+ cur.node = n.nextSibling;
222
+ return n;
223
+ }
224
+
225
+ /** Step a component's own adopt walk past its `{children}` slot — claim the
226
+ * `<!--c[-->`…`<!--c]-->` markers and skip the slotted nodes (the parent owns their
227
+ * reactivity, wired separately by {@link hydrateSlot}), leaving them in place. Advances the
228
+ * cursor past the closing marker. Throws {@link HydrationMismatch} on a missing start marker. */
229
+ export function skipSlot(cur) {
230
+ const start = cur.node;
231
+ if (!start || start.nodeType !== COMMENT || start.data !== SLOT_START) {
232
+ throw new HydrationMismatch(`expected a children-slot marker, found ${describe(start)}`);
233
+ }
234
+ let n = start.nextSibling;
235
+ while (n && !(n.nodeType === COMMENT && n.data === SLOT_END)) n = n.nextSibling;
236
+ cur.node = n ? n.nextSibling : null; // step past the `<!--c]-->` end marker
237
+ }
238
+
239
+ /**
240
+ * Adopt a component's slotted children — the *parent's* JSX, server-rendered inside the
241
+ * component host at its `{children}` slot. The parent owns their reactivity but the
242
+ * component decides where they sit, so the parent locates the slot by its `<!--c[-->` marker
243
+ * within `host` and adopts from the first slotted node via `adoptFn(cursor)`. Independent of
244
+ * when the component upgrades: the markers are static server DOM and adoption only *wires*
245
+ * (never moves) nodes, so it commutes with the component's own `skipSlot`. A missing marker
246
+ * (a rebuilt component, or no children) is a no-op.
247
+ */
248
+ export function hydrateSlot(host, adoptFn) {
249
+ const start = findComment(host, SLOT_START);
250
+ if (start) adoptFn({ node: start.nextSibling });
251
+ }
252
+
253
+ /** The first descendant comment of `root` whose data is `data` (tree order). */
254
+ function findComment(root, data) {
255
+ const walker = document.createTreeWalker(root, 128 /* NodeFilter.SHOW_COMMENT */);
256
+ let n;
257
+ while ((n = walker.nextNode())) if (n.data === data) return n;
258
+ return null;
259
+ }
260
+
114
261
  /** A short human description of a node for mismatch messages. */
115
262
  function describe(node) {
116
263
  if (!node) return "nothing";
package/runtime/index.js CHANGED
@@ -14,6 +14,8 @@ export * from "./mount.js";
14
14
  export * from "./hydrate.js";
15
15
  export * from "./lifecycle.js";
16
16
  export * from "./router.js";
17
+ export * from "./route-data.js";
18
+ export * from "./resource.js";
17
19
  export * from "./context.js";
18
20
  export * from "./error-boundary.js";
19
21
  export * from "./portal.js";
package/runtime/mount.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // SPEC §2.2). Components compile to Custom Elements and mount via the DOM.
3
3
 
4
4
  import { reportError } from "../core/errors.js";
5
+ import { scope } from "../core/signals.js";
5
6
 
6
7
  /**
7
8
  * Mount a view into `target`. `view` is either a factory function returning a
@@ -11,9 +12,21 @@ import { reportError } from "../core/errors.js";
11
12
  * built from `onMount`/`onCleanup`. After insertion we run the `onMount` callbacks,
12
13
  * collecting any returned disposer into `cleanups` so a caller (e.g. the router)
13
14
  * can tear the page down on navigation.
15
+ *
16
+ * A factory build runs inside a reactive `scope`, and the scope's disposer joins
17
+ * the cleanups: teardown (router navigation) stops every binding effect the page
18
+ * created, so a replaced page's subscriptions don't outlive it.
14
19
  */
15
20
  export function mount(view, target) {
16
- const node = typeof view === "function" ? view() : view;
21
+ let node;
22
+ if (typeof view === "function") {
23
+ const s = scope(view);
24
+ node = s.result;
25
+ const lc = (node.__lifecycle ??= { mounts: [], cleanups: [] });
26
+ lc.cleanups.push(s.dispose);
27
+ } else {
28
+ node = view;
29
+ }
17
30
  target.appendChild(node);
18
31
  runMount(node);
19
32
  return node;
@@ -0,0 +1,139 @@
1
+ // `resource()` — the client-side async-data primitive (SPEC §7.4, docs/DATA.md).
2
+ // Components are strictly synchronous (SPEC §5.5), so async data lives *next to*
3
+ // the view: a resource wraps a fetcher in signals and the view renders its
4
+ // reactive `loading` / `error` / `data` states:
5
+ //
6
+ // const users = resource(() => fetch("/api/users").then((r) => r.json()));
7
+ // // in JSX: {users.loading ? <Spinner /> : users.data.map(...)}
8
+ //
9
+ // With a reactive source, the fetch re-runs whenever the source changes, and a
10
+ // `null`/`false` source pauses it (conditional fetching):
11
+ //
12
+ // const user = resource(() => router.params.id, (id, { signal }) =>
13
+ // fetch(`/api/users/${id}`, { signal }).then((r) => r.json()));
14
+ //
15
+ // Staleness: each run bumps a token and aborts the previous run's AbortController
16
+ // (passed to the fetcher as `{ signal }`), so an out-of-order resolution can never
17
+ // overwrite newer data — even for fetchers that ignore the signal.
18
+ //
19
+ // Server (SSG/SSR): no effect is created and nothing is fetched — `loading` stays
20
+ // `true`, so the prerendered HTML shows the loading branch. That is exactly what
21
+ // the client's first paint renders before its own fetch resolves, keeping
22
+ // hydration adoption aligned.
23
+
24
+ import { batch, effect, signal, untracked } from "../core/signals.js";
25
+
26
+ // Test override: bun tests run under happy-dom (a `document` always exists), so
27
+ // server behavior is opted into explicitly.
28
+ let serverOverride = null;
29
+
30
+ /** Force server (true) / client (false) behavior for new resources — tests only. */
31
+ export function __setResourceServer(v) {
32
+ serverOverride = v;
33
+ }
34
+
35
+ const isServer = () => serverOverride ?? typeof document === "undefined";
36
+
37
+ /**
38
+ * Create a resource. Two call shapes:
39
+ *
40
+ * resource(fetcher, options?) — fetch once (and on refetch())
41
+ * resource(source, fetcher, options?) — re-fetch when the reactive `source`
42
+ * changes; `null`/`false` pauses
43
+ *
44
+ * @param {Function} source reactive source read inside the tracking effect; its
45
+ * value is passed to the fetcher.
46
+ * @param {Function} fetcher `(sourceValue, { signal }) => data | Promise<data>`.
47
+ * @param {{ initial?: unknown }} [options] `initial` seeds `data` before the
48
+ * first resolution.
49
+ * @returns {{ data: unknown, loading: boolean, error: unknown,
50
+ * refetch: () => Promise<unknown> | undefined }} reactive getters —
51
+ * read them inside bindings/effects to subscribe.
52
+ */
53
+ export function resource(source, fetcher, options = {}) {
54
+ if (typeof fetcher !== "function") {
55
+ options = fetcher ?? {};
56
+ fetcher = source;
57
+ source = null;
58
+ }
59
+ const src = typeof source === "function" ? source : () => true;
60
+ const data = signal(options.initial);
61
+ const error = signal(undefined);
62
+ const loading = signal(true); // a fetch is intended; the server leaves it true
63
+ let token = 0;
64
+ let controller = null;
65
+
66
+ function run(value) {
67
+ controller?.abort();
68
+ // Captured per-run: by the time a superseded run's promise settles, the
69
+ // module-level `controller` already belongs to a newer run.
70
+ const c = (controller = typeof AbortController !== "undefined" ? new AbortController() : null);
71
+ const t = ++token;
72
+ batch(() => {
73
+ loading.value = true;
74
+ error.value = undefined;
75
+ });
76
+ // The async IIFE starts the fetcher *synchronously* (no wasted microtask)
77
+ // while converting a synchronous throw into a rejection.
78
+ return (async () => fetcher(value, { signal: c?.signal }))()
79
+ .then(
80
+ (v) => {
81
+ if (t !== token) return; // superseded — a newer run owns the signals
82
+ batch(() => {
83
+ data.value = v;
84
+ loading.value = false;
85
+ });
86
+ return v;
87
+ },
88
+ (e) => {
89
+ if (t !== token) return;
90
+ // Keep the last good `data` (stale-while-error); only `error` flips.
91
+ batch(() => {
92
+ error.value = e;
93
+ loading.value = false;
94
+ });
95
+ },
96
+ );
97
+ }
98
+
99
+ if (!isServer()) {
100
+ effect(() => {
101
+ const value = src(); // the effect's ONLY tracked read
102
+ if (value == null || value === false) {
103
+ // Paused: cancel anything in flight and settle as "not loading".
104
+ untracked(() => {
105
+ token++;
106
+ controller?.abort();
107
+ controller = null;
108
+ loading.value = false;
109
+ });
110
+ return;
111
+ }
112
+ untracked(() => run(value));
113
+ // Before a re-run (source changed) and on scope dispose: invalidate + abort,
114
+ // so a torn-down region's fetch can neither land nor leak a connection.
115
+ return () => {
116
+ token++;
117
+ controller?.abort();
118
+ };
119
+ });
120
+ }
121
+
122
+ return {
123
+ get data() {
124
+ return data.value;
125
+ },
126
+ get loading() {
127
+ return loading.value;
128
+ },
129
+ get error() {
130
+ return error.value;
131
+ },
132
+ /** Re-run the fetcher with the current source value (no-op while paused). */
133
+ refetch() {
134
+ const value = untracked(src);
135
+ if (value == null || value === false) return undefined;
136
+ return run(value);
137
+ },
138
+ };
139
+ }