@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 +42 -1
- package/package.json +9 -2
- package/runtime/dom.js +109 -10
- package/runtime/hydrate.js +150 -3
- package/runtime/index.js +2 -0
- package/runtime/mount.js +14 -1
- package/runtime/resource.js +139 -0
- package/runtime/route-data.js +64 -0
- package/runtime/router.js +144 -19
- package/server/adapters/node.d.ts +28 -0
- package/server/adapters/node.js +74 -0
- package/server/api.d.ts +106 -0
- package/server/api.js +208 -0
- package/server/index.d.ts +92 -0
- package/server/index.js +2 -0
- package/server/loader.js +199 -0
- package/server/render.js +19 -10
- package/server/ssg-runtime.js +53 -2
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
|
-
|
|
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.
|
|
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":
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
283
|
-
|
|
329
|
+
return reconcileList(anchor, sourceFn, renderItem, keyFn, new Map(), []);
|
|
330
|
+
}
|
|
284
331
|
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|
package/runtime/hydrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|