@opentf/web 0.7.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 +43 -13
- 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 +75 -10
- 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 +6 -4
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,7 @@
|
|
|
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
6
|
import { claimRegionEnd, claimRegionStart } from "./hydrate.js";
|
|
7
7
|
|
|
8
8
|
// Attribute names that must be assigned as JS properties (not setAttribute) for
|
|
@@ -269,32 +269,47 @@ export function bindChild(anchor, fn) {
|
|
|
269
269
|
*/
|
|
270
270
|
export function hydrateChild(cur, adoptFn, buildFn) {
|
|
271
271
|
claimRegionStart(cur);
|
|
272
|
-
|
|
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)));
|
|
273
275
|
const anchor = claimRegionEnd(cur);
|
|
274
276
|
// Seed with the adopted nodes and skip the first effect run's DOM write: the branch is
|
|
275
277
|
// already in place. The first run still evaluates `buildFn` to subscribe to its reactive
|
|
276
278
|
// deps (the built nodes are discarded); from the second run on it swaps normally.
|
|
277
|
-
return childEffect(anchor, buildFn,
|
|
279
|
+
return childEffect(anchor, buildFn, adoptedScope.result, true, adoptedScope);
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/** The child-region effect shared by {@link bindChild} (empty seed, no skip) and
|
|
281
283
|
* {@link hydrateChild} (seeded with adopted nodes, first DOM write skipped). On each run
|
|
282
|
-
* the previous nodes are replaced with the new ones, inserted before `anchor
|
|
283
|
-
|
|
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) {
|
|
284
287
|
let first = skipFirst;
|
|
285
|
-
|
|
286
|
-
|
|
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()));
|
|
287
292
|
if (first) {
|
|
288
293
|
first = false;
|
|
289
|
-
|
|
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
|
|
290
298
|
}
|
|
299
|
+
const next = built.result;
|
|
291
300
|
const host = anchor.parentNode;
|
|
292
301
|
for (const n of current) {
|
|
293
302
|
if (n.parentNode === host) host.removeChild(n);
|
|
294
303
|
}
|
|
295
304
|
if (host) for (const n of next) host.insertBefore(n, anchor);
|
|
305
|
+
if (currentScope) currentScope.dispose();
|
|
306
|
+
currentScope = built;
|
|
296
307
|
current = next;
|
|
297
308
|
});
|
|
309
|
+
return () => {
|
|
310
|
+
disposeEffect();
|
|
311
|
+
if (currentScope) currentScope.dispose();
|
|
312
|
+
};
|
|
298
313
|
}
|
|
299
314
|
|
|
300
315
|
/**
|
|
@@ -333,8 +348,10 @@ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
|
|
|
333
348
|
const item = items[index];
|
|
334
349
|
const key = keyFn ? keyFn(item, index) : index;
|
|
335
350
|
const sig = signal(item);
|
|
336
|
-
|
|
337
|
-
|
|
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 });
|
|
338
355
|
prevKeys.push(key);
|
|
339
356
|
}
|
|
340
357
|
const anchor = claimRegionEnd(cur);
|
|
@@ -350,7 +367,7 @@ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
|
|
|
350
367
|
* order) carry reconciliation state across runs. Returns the effect disposer.
|
|
351
368
|
*/
|
|
352
369
|
function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
353
|
-
|
|
370
|
+
const disposeEffect = effect(() => {
|
|
354
371
|
const data = sourceFn();
|
|
355
372
|
const items = Array.isArray(data) ? data : [];
|
|
356
373
|
const next = new Map();
|
|
@@ -373,7 +390,12 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
373
390
|
prevIndex[index] = prevPos.has(key) ? prevPos.get(key) : -1;
|
|
374
391
|
} else {
|
|
375
392
|
const sig = signal(item);
|
|
376
|
-
|
|
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 };
|
|
377
399
|
prevIndex[index] = -1;
|
|
378
400
|
}
|
|
379
401
|
next.set(key, entry);
|
|
@@ -381,8 +403,9 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
381
403
|
nodes[index] = entry.node;
|
|
382
404
|
}
|
|
383
405
|
|
|
384
|
-
// Remove nodes whose keys disappeared.
|
|
406
|
+
// Remove nodes whose keys disappeared, and dispose their bindings.
|
|
385
407
|
for (const entry of cache.values()) {
|
|
408
|
+
if (entry.dispose) entry.dispose();
|
|
386
409
|
if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
|
|
387
410
|
}
|
|
388
411
|
cache = next;
|
|
@@ -403,6 +426,13 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
403
426
|
if (nodes[i].nextSibling !== ref) host.insertBefore(nodes[i], ref);
|
|
404
427
|
}
|
|
405
428
|
});
|
|
429
|
+
return () => {
|
|
430
|
+
disposeEffect();
|
|
431
|
+
for (const entry of cache.values()) {
|
|
432
|
+
if (entry.dispose) entry.dispose();
|
|
433
|
+
}
|
|
434
|
+
cache.clear();
|
|
435
|
+
};
|
|
406
436
|
}
|
|
407
437
|
|
|
408
438
|
/**
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Route-loader data on the client (docs/DATA.md). A page with a sibling
|
|
2
|
+
// `loader.{js,ts}` gets its data three ways, all converging on `router.data`:
|
|
3
|
+
//
|
|
4
|
+
// • first paint over server HTML — the payload is inlined as
|
|
5
|
+
// `<script type="application/json" id="__otfw_data">` and read here;
|
|
6
|
+
// • SPA navigation — fetched from `<path>/__data.json` (the same URL a static
|
|
7
|
+
// host serves as a literal file written at SSG time, and the serve/dev
|
|
8
|
+
// servers answer dynamically);
|
|
9
|
+
// • dev/CSR first load — the same fetch, since there is no server markup.
|
|
10
|
+
//
|
|
11
|
+
// The endpoint returns the raw loader JSON (no envelope): 404 means "no data for
|
|
12
|
+
// this route" (no loader, or the loader called `notFound()`) and maps to
|
|
13
|
+
// `undefined` so the page renders its empty state.
|
|
14
|
+
|
|
15
|
+
/** The reserved per-route data filename/URL suffix (`/todos` → `/todos/__data.json`). */
|
|
16
|
+
export const DATA_FILE = "__data.json";
|
|
17
|
+
|
|
18
|
+
// Match the router's path normalization (a trailing slash must hit the same URL).
|
|
19
|
+
const normalize = (p) => (p || "/").replace(/(.)\/+$/, "$1");
|
|
20
|
+
|
|
21
|
+
/** The data-endpoint URL for a page path: `"/"` → `/__data.json`,
|
|
22
|
+
* `"/todos"` → `/todos/__data.json`, preserving the query string. */
|
|
23
|
+
export function dataUrlFor(pathname, search = "") {
|
|
24
|
+
const path = normalize(pathname);
|
|
25
|
+
return (path === "/" ? `/${DATA_FILE}` : `${path}/${DATA_FILE}`) + (search || "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a route's loader data. 200 → the parsed JSON; 404 → `undefined` (no
|
|
30
|
+
* loader / `notFound()`); anything else throws (the router reports it and
|
|
31
|
+
* commits the navigation with no data).
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchRouteData(pathname, search = "") {
|
|
34
|
+
const res = await fetch(dataUrlFor(pathname, search));
|
|
35
|
+
if (res.status === 404) return undefined;
|
|
36
|
+
if (!res.ok) throw new Error(`route data request for ${pathname} failed (${res.status})`);
|
|
37
|
+
return res.json();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The inline first-paint payload, read once and cached (mirrors the island-props
|
|
41
|
+
// payload reader in hydrate.js). `undefined` data serializes to *no script at all*
|
|
42
|
+
// (the toolchain skips injection), so a missing element simply reads as undefined.
|
|
43
|
+
let _read = false;
|
|
44
|
+
let _value;
|
|
45
|
+
|
|
46
|
+
/** The loader data inlined by the server for the current document, or `undefined`. */
|
|
47
|
+
export function readInlineRouteData() {
|
|
48
|
+
if (!_read) {
|
|
49
|
+
_read = true;
|
|
50
|
+
const el = typeof document !== "undefined" ? document.getElementById("__otfw_data") : null;
|
|
51
|
+
try {
|
|
52
|
+
_value = el ? JSON.parse(el.textContent || "null") : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
_value = undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return _value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reset the cached inline payload (tests only — a fresh document between cases). */
|
|
61
|
+
export function __resetInlineRouteData() {
|
|
62
|
+
_read = false;
|
|
63
|
+
_value = undefined;
|
|
64
|
+
}
|
package/runtime/router.js
CHANGED
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
// NOTE: passing route params to a page via its `props` argument
|
|
12
12
|
// (`function Page(props) { props.params.id }`) and layout `props.children`
|
|
13
13
|
// composition both require signal-free page props, which the compiler does not
|
|
14
|
-
// emit yet — for now pages read params via the reactive `router.params
|
|
14
|
+
// emit yet — for now pages read params via the reactive `router.params`, and a
|
|
15
|
+
// route loader's data via the reactive `router.data` (docs/DATA.md).
|
|
15
16
|
|
|
16
17
|
import { clearError, reportError } from "../core/errors.js";
|
|
17
18
|
import { signal } from "../core/signals.js";
|
|
18
19
|
import { beginHydration, cursor, endHydration } from "./hydrate.js";
|
|
19
20
|
import { runCleanup, runMount } from "./mount.js";
|
|
21
|
+
import { fetchRouteData, readInlineRouteData } from "./route-data.js";
|
|
20
22
|
|
|
21
23
|
const isBrowser = typeof window !== "undefined";
|
|
22
24
|
|
|
@@ -34,9 +36,10 @@ const state = {
|
|
|
34
36
|
searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
|
|
35
37
|
params: signal({}),
|
|
36
38
|
locale: signal(null),
|
|
39
|
+
data: signal(undefined),
|
|
37
40
|
};
|
|
38
41
|
|
|
39
|
-
export const routes = { pages: {}, layouts: {}, notFound: null };
|
|
42
|
+
export const routes = { pages: {}, layouts: {}, notFound: null, loaderRoutes: new Set() };
|
|
40
43
|
let guard = null;
|
|
41
44
|
let rootEl = null;
|
|
42
45
|
|
|
@@ -135,14 +138,19 @@ export const router = {
|
|
|
135
138
|
get locale() {
|
|
136
139
|
return state.locale.value;
|
|
137
140
|
},
|
|
141
|
+
get data() {
|
|
142
|
+
return state.data.value;
|
|
143
|
+
},
|
|
138
144
|
push: (path) => navigate(path),
|
|
139
145
|
replace: (path) => navigate(path, true),
|
|
140
146
|
};
|
|
141
147
|
|
|
142
|
-
/** Derive the route ("/counter", "/") from a `.../app/<route>/page.jsx` path.
|
|
148
|
+
/** Derive the route ("/counter", "/") from a `.../app/<route>/page.jsx` path. The
|
|
149
|
+
* lookahead pins `/app` to a complete path segment, so a route folder that merely
|
|
150
|
+
* starts with "app" (`/appointments`) isn't clipped. */
|
|
143
151
|
function routeFromPath(filePath) {
|
|
144
152
|
const r = filePath
|
|
145
|
-
.replace(/^.*\/app/, "")
|
|
153
|
+
.replace(/^.*\/app(?=\/)/, "")
|
|
146
154
|
.replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
|
|
147
155
|
return r === "" ? "/" : r;
|
|
148
156
|
}
|
|
@@ -162,6 +170,22 @@ export function registerRoutes(modules) {
|
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Register which route *patterns* (`"/todos"`, `"/items/[id]"`) have a server
|
|
175
|
+
* loader (docs/DATA.md) — the toolchain discovers `loader.{js,ts}` files and
|
|
176
|
+
* passes the list via `mountApp({ loaders })`. `navigate` only fetches
|
|
177
|
+
* `<path>/__data.json` for routes in this set; `matchRoute` returns the same
|
|
178
|
+
* pattern string, so membership is a plain Set lookup.
|
|
179
|
+
*/
|
|
180
|
+
export function registerLoaderRoutes(paths) {
|
|
181
|
+
routes.loaderRoutes = new Set(paths || []);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Set the reactive `router.data` directly (server render and tests). */
|
|
185
|
+
export function setRouteData(data) {
|
|
186
|
+
state.data.value = data;
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
/** Layout entries that wrap `route`, outermost (root) first. */
|
|
166
190
|
export function layoutChain(route) {
|
|
167
191
|
const chain = [];
|
|
@@ -255,11 +279,14 @@ async function resolveModule(entry) {
|
|
|
255
279
|
* so a page reading `router.pathname`/`params`/`query` resolves to the route being
|
|
256
280
|
* pre-rendered. The client uses `navigate` instead.
|
|
257
281
|
*/
|
|
258
|
-
export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
|
|
282
|
+
export function setRouteState({ pathname = "/", search = "", params = {}, locale, data } = {}) {
|
|
259
283
|
state.pathname.value = normalizePath(pathname);
|
|
260
284
|
state.searchParams.value = new URLSearchParams(search);
|
|
261
285
|
state.params.value = params;
|
|
262
286
|
state.locale.value = locale !== undefined ? locale : resolveLocale(pathname).locale;
|
|
287
|
+
// Always assigned (even when the caller passed none) so a loader-less render
|
|
288
|
+
// never shows a previous route's stale data.
|
|
289
|
+
state.data.value = data;
|
|
263
290
|
}
|
|
264
291
|
|
|
265
292
|
/**
|
|
@@ -270,9 +297,16 @@ export function setRouteState({ pathname = "/", search = "", params = {}, locale
|
|
|
270
297
|
export function matchRoute(pathname) {
|
|
271
298
|
pathname = resolveLocale(pathname).path;
|
|
272
299
|
for (const route in routes.pages) {
|
|
300
|
+
// `[param]` / `[...rest]` become named groups; literal parts are regex-escaped
|
|
301
|
+
// (a `v1.0` folder must not match `v1X0`).
|
|
273
302
|
const pattern = route
|
|
274
|
-
.
|
|
275
|
-
.
|
|
303
|
+
.split(/(\[\.\.\.[^\]]+\]|\[[^\]]+\])/)
|
|
304
|
+
.map((part) => {
|
|
305
|
+
if (part.startsWith("[...")) return `(?<${part.slice(4, -1)}>.+)`;
|
|
306
|
+
if (part.startsWith("[")) return `(?<${part.slice(1, -1)}>[^/]+)`;
|
|
307
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
308
|
+
})
|
|
309
|
+
.join("");
|
|
276
310
|
const m = pathname.match(new RegExp(`^${pattern}/?$`));
|
|
277
311
|
if (m) {
|
|
278
312
|
const params = { ...(m.groups || {}) };
|
|
@@ -285,12 +319,20 @@ export function matchRoute(pathname) {
|
|
|
285
319
|
return null;
|
|
286
320
|
}
|
|
287
321
|
|
|
322
|
+
// Monotonic navigation sequence: a navigation that awaited its loader-data fetch
|
|
323
|
+
// commits only if no newer navigation started meanwhile (stale data must never
|
|
324
|
+
// win over a later click).
|
|
325
|
+
let navSeq = 0;
|
|
326
|
+
|
|
288
327
|
/**
|
|
289
|
-
* Navigate to `path`. Runs an optional route guard,
|
|
290
|
-
* (
|
|
328
|
+
* Navigate to `path`. Runs an optional route guard, fetches the route's loader
|
|
329
|
+
* data when it has any (docs/DATA.md — fetch *then* commit, like the guard),
|
|
330
|
+
* swaps the rendered page (tearing down the previous one's lifecycle), and
|
|
331
|
+
* updates window.history.
|
|
291
332
|
*/
|
|
292
333
|
export async function navigate(path, replace = false, isPop = false, hydrate = false) {
|
|
293
334
|
if (!path || !rootEl) return;
|
|
335
|
+
const seq = ++navSeq;
|
|
294
336
|
const url = new URL(path, window.location.origin);
|
|
295
337
|
|
|
296
338
|
if (guard) {
|
|
@@ -323,10 +365,30 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
|
|
|
323
365
|
matchRoute(url.pathname) ||
|
|
324
366
|
(routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
|
|
325
367
|
|
|
368
|
+
// Loader data (docs/DATA.md): resolved before any state write / history push /
|
|
369
|
+
// DOM swap, mirroring the guard's "settle before commit" flow. First paint over
|
|
370
|
+
// server HTML reads the inlined payload; every other navigation (SPA nav, dev/CSR
|
|
371
|
+
// first load, popstate) fetches the `<path>/__data.json` endpoint. A failed fetch
|
|
372
|
+
// is reported and the navigation commits with `data === undefined`.
|
|
373
|
+
let data;
|
|
374
|
+
if (match && match.route && routes.loaderRoutes.has(match.route)) {
|
|
375
|
+
if (hydrate) {
|
|
376
|
+
data = readInlineRouteData();
|
|
377
|
+
} else {
|
|
378
|
+
try {
|
|
379
|
+
data = await fetchRouteData(url.pathname, url.search);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
reportError(e, { phase: "data", path: url.pathname });
|
|
382
|
+
}
|
|
383
|
+
if (seq !== navSeq) return; // a newer navigation superseded this one
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
326
387
|
state.pathname.value = normalizePath(url.pathname);
|
|
327
388
|
state.searchParams.value = url.searchParams;
|
|
328
389
|
state.params.value = match ? match.params : {};
|
|
329
390
|
state.locale.value = resolveLocale(url.pathname).locale;
|
|
391
|
+
state.data.value = data;
|
|
330
392
|
|
|
331
393
|
if (!isPop) {
|
|
332
394
|
if (replace) window.history.replaceState({}, "", path);
|
|
@@ -412,12 +474,15 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
|
|
|
412
474
|
* @param {Function} [opts.guard] optional `(to, tools) => …` route guard.
|
|
413
475
|
* @param {"spa"|"mpa"} [opts.nav] navigation mode (default "spa"); "mpa" disables
|
|
414
476
|
* client-side link interception so every navigation is a full page load.
|
|
477
|
+
* @param {string[]} [opts.loaders] route patterns that have a server loader
|
|
478
|
+
* (docs/DATA.md) — navigation fetches `<path>/__data.json` for these.
|
|
415
479
|
*/
|
|
416
|
-
export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
|
|
480
|
+
export function mountApp({ pages, target, guard: g, i18n, nav, loaders } = {}) {
|
|
417
481
|
rootEl = target || (isBrowser ? document.getElementById("app") : null);
|
|
418
482
|
navMode = nav === "mpa" ? "mpa" : "spa";
|
|
419
483
|
if (i18n) configureI18n(i18n);
|
|
420
484
|
if (pages) registerRoutes(pages);
|
|
485
|
+
if (loaders) registerLoaderRoutes(loaders);
|
|
421
486
|
guard = g || null;
|
|
422
487
|
// In MPA mode the browser owns navigation (full loads push real history entries),
|
|
423
488
|
// so there is no client-side history to react to — only wire popstate for SPA.
|