@opentf/web 0.7.0 → 0.9.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 +63 -13
- package/runtime/hydrate.js +43 -1
- 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 +81 -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.9.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
|
|
@@ -147,6 +147,26 @@ export function setAttr(el, name, value) {
|
|
|
147
147
|
* component picks it up from `getAttribute` when it upgrades.
|
|
148
148
|
*/
|
|
149
149
|
export function setProp(el, name, value) {
|
|
150
|
+
// A component prop must land as a *property* (carrying the rich value), never a
|
|
151
|
+
// stringified attribute. A server-parsed custom element that a parent's adopt walk
|
|
152
|
+
// reaches while it is still inert — its own `customElements.define`-driven upgrade
|
|
153
|
+
// hasn't run yet (synchronous upgrades fire in document order, so a parent upgrades
|
|
154
|
+
// and runs this before its later-in-DOM children) — has no accessors, so `name in el`
|
|
155
|
+
// is false. Falling back to `setAttr` would stringify an object to "[object Object]"
|
|
156
|
+
// and, via `attributeChangedCallback`, clobber the child's payload-hydrated signal —
|
|
157
|
+
// flipping conditional branches and desyncing the adopt walk. Force the pending upgrade
|
|
158
|
+
// first so the property setter exists; it's a no-op for already-upgraded or non-custom
|
|
159
|
+
// elements. (A client CSR build creates already-upgraded instances, so this never fires
|
|
160
|
+
// there.)
|
|
161
|
+
if (
|
|
162
|
+
!(name in el) &&
|
|
163
|
+
el.tagName &&
|
|
164
|
+
el.tagName.includes("-") &&
|
|
165
|
+
typeof customElements !== "undefined" &&
|
|
166
|
+
customElements.get(el.tagName.toLowerCase())
|
|
167
|
+
) {
|
|
168
|
+
customElements.upgrade(el);
|
|
169
|
+
}
|
|
150
170
|
if (name in el) el[name] = value;
|
|
151
171
|
else setAttr(el, name, value);
|
|
152
172
|
}
|
|
@@ -269,32 +289,47 @@ export function bindChild(anchor, fn) {
|
|
|
269
289
|
*/
|
|
270
290
|
export function hydrateChild(cur, adoptFn, buildFn) {
|
|
271
291
|
claimRegionStart(cur);
|
|
272
|
-
|
|
292
|
+
// Adopt inside a scope so the adopted branch owns its bindings' effects; the
|
|
293
|
+
// region disposes it when a reactive change swaps the branch out.
|
|
294
|
+
const adoptedScope = scope(() => toNodes(adoptFn(cur)));
|
|
273
295
|
const anchor = claimRegionEnd(cur);
|
|
274
296
|
// Seed with the adopted nodes and skip the first effect run's DOM write: the branch is
|
|
275
297
|
// already in place. The first run still evaluates `buildFn` to subscribe to its reactive
|
|
276
298
|
// deps (the built nodes are discarded); from the second run on it swaps normally.
|
|
277
|
-
return childEffect(anchor, buildFn,
|
|
299
|
+
return childEffect(anchor, buildFn, adoptedScope.result, true, adoptedScope);
|
|
278
300
|
}
|
|
279
301
|
|
|
280
302
|
/** The child-region effect shared by {@link bindChild} (empty seed, no skip) and
|
|
281
303
|
* {@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
|
-
|
|
304
|
+
* the previous nodes are replaced with the new ones, inserted before `anchor`; the
|
|
305
|
+
* previous branch's scope is disposed so its bindings' effects don't outlive it. */
|
|
306
|
+
function childEffect(anchor, fn, current, skipFirst, currentScope = null) {
|
|
284
307
|
let first = skipFirst;
|
|
285
|
-
|
|
286
|
-
|
|
308
|
+
const disposeEffect = effect(() => {
|
|
309
|
+
// Build in a scope: the branch's nested bindings (text/attr/list effects)
|
|
310
|
+
// belong to this run and are disposed when the branch is replaced.
|
|
311
|
+
const built = scope(() => toNodes(fn()));
|
|
287
312
|
if (first) {
|
|
288
313
|
first = false;
|
|
289
|
-
|
|
314
|
+
// Hydration first run subscribes the region effect only; the discarded
|
|
315
|
+
// build's own effects must not stay live alongside the adopted branch.
|
|
316
|
+
built.dispose();
|
|
317
|
+
return; // keep the adopted DOM on first paint
|
|
290
318
|
}
|
|
319
|
+
const next = built.result;
|
|
291
320
|
const host = anchor.parentNode;
|
|
292
321
|
for (const n of current) {
|
|
293
322
|
if (n.parentNode === host) host.removeChild(n);
|
|
294
323
|
}
|
|
295
324
|
if (host) for (const n of next) host.insertBefore(n, anchor);
|
|
325
|
+
if (currentScope) currentScope.dispose();
|
|
326
|
+
currentScope = built;
|
|
296
327
|
current = next;
|
|
297
328
|
});
|
|
329
|
+
return () => {
|
|
330
|
+
disposeEffect();
|
|
331
|
+
if (currentScope) currentScope.dispose();
|
|
332
|
+
};
|
|
298
333
|
}
|
|
299
334
|
|
|
300
335
|
/**
|
|
@@ -333,8 +368,10 @@ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
|
|
|
333
368
|
const item = items[index];
|
|
334
369
|
const key = keyFn ? keyFn(item, index) : index;
|
|
335
370
|
const sig = signal(item);
|
|
336
|
-
|
|
337
|
-
|
|
371
|
+
// Adopt inside a scope so the item owns its bindings' effects; eviction
|
|
372
|
+
// disposes them (same ownership as CSR-built items in reconcileList).
|
|
373
|
+
const s = scope(() => adoptItem(cur, sig, index)); // claims one item subtree off `cur`
|
|
374
|
+
cache.set(key, { sig, node: s.result, dispose: s.dispose });
|
|
338
375
|
prevKeys.push(key);
|
|
339
376
|
}
|
|
340
377
|
const anchor = claimRegionEnd(cur);
|
|
@@ -350,7 +387,7 @@ export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
|
|
|
350
387
|
* order) carry reconciliation state across runs. Returns the effect disposer.
|
|
351
388
|
*/
|
|
352
389
|
function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
353
|
-
|
|
390
|
+
const disposeEffect = effect(() => {
|
|
354
391
|
const data = sourceFn();
|
|
355
392
|
const items = Array.isArray(data) ? data : [];
|
|
356
393
|
const next = new Map();
|
|
@@ -373,7 +410,12 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
373
410
|
prevIndex[index] = prevPos.has(key) ? prevPos.get(key) : -1;
|
|
374
411
|
} else {
|
|
375
412
|
const sig = signal(item);
|
|
376
|
-
|
|
413
|
+
// Build inside a scope: the item owns its bindings' effects, so
|
|
414
|
+
// evicting it detaches them from shared signals (e.g. a selection
|
|
415
|
+
// signal every row's class reads). Without this every discarded row
|
|
416
|
+
// leaves a zombie effect that all later writes keep re-running.
|
|
417
|
+
const s = scope(() => renderItem(sig, index));
|
|
418
|
+
entry = { sig, node: s.result, dispose: s.dispose };
|
|
377
419
|
prevIndex[index] = -1;
|
|
378
420
|
}
|
|
379
421
|
next.set(key, entry);
|
|
@@ -381,8 +423,9 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
381
423
|
nodes[index] = entry.node;
|
|
382
424
|
}
|
|
383
425
|
|
|
384
|
-
// Remove nodes whose keys disappeared.
|
|
426
|
+
// Remove nodes whose keys disappeared, and dispose their bindings.
|
|
385
427
|
for (const entry of cache.values()) {
|
|
428
|
+
if (entry.dispose) entry.dispose();
|
|
386
429
|
if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
|
|
387
430
|
}
|
|
388
431
|
cache = next;
|
|
@@ -403,6 +446,13 @@ function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
|
403
446
|
if (nodes[i].nextSibling !== ref) host.insertBefore(nodes[i], ref);
|
|
404
447
|
}
|
|
405
448
|
});
|
|
449
|
+
return () => {
|
|
450
|
+
disposeEffect();
|
|
451
|
+
for (const entry of cache.values()) {
|
|
452
|
+
if (entry.dispose) entry.dispose();
|
|
453
|
+
}
|
|
454
|
+
cache.clear();
|
|
455
|
+
};
|
|
406
456
|
}
|
|
407
457
|
|
|
408
458
|
/**
|
package/runtime/hydrate.js
CHANGED
|
@@ -58,13 +58,55 @@ export class HydrationMismatch extends Error {
|
|
|
58
58
|
// `firstChild`, so the structural test alone can't tell "adopt the server DOM" from
|
|
59
59
|
// "build and slot these children" (it mis-adopts on plain SPA navigation). The flag is
|
|
60
60
|
// the unambiguous signal — it is only ever set during the one-shot first-paint pass.
|
|
61
|
-
|
|
61
|
+
//
|
|
62
|
+
// Why it initializes from the DOM sentinel, not `false`: a custom element upgrades — and
|
|
63
|
+
// its `connectedCallback` runs the build-vs-adopt switch — the instant its class is
|
|
64
|
+
// `customElements.define`d. Framework components imported *eagerly* by the app entry
|
|
65
|
+
// (e.g. `<Link>` from `@opentf/web`) are therefore defined and upgraded during the entry
|
|
66
|
+
// bundle's evaluation, **before** `mountApp` runs `beginHydration()`. If the flag were
|
|
67
|
+
// still `false` then, every server-rendered `<web-link>` would take the *build* arm —
|
|
68
|
+
// re-wrapping the server subtree it should have adopted (a silent `<a><a>` double-build)
|
|
69
|
+
// — long before the router ever starts hydrating. So the flag is seeded synchronously
|
|
70
|
+
// at module load from the server sentinel (`[data-otfw-hydrate]`, stamped only when the
|
|
71
|
+
// page shipped adoptable server markup): the deferred entry module evaluates after the
|
|
72
|
+
// HTML is parsed, so the sentinel is present, and this module is a transitive dependency
|
|
73
|
+
// of every component (via the runtime), so it evaluates before any component's `define`.
|
|
74
|
+
// `mountApp` still brackets the pass with `beginHydration`/`endHydration`, and clears the
|
|
75
|
+
// flag when it decides *not* to hydrate (no sentinel / empty root), so a CSR mount and
|
|
76
|
+
// every subsequent SPA navigation build fresh.
|
|
77
|
+
let _hydrating =
|
|
78
|
+
typeof document !== "undefined" && !!document.querySelector("[data-otfw-hydrate]");
|
|
62
79
|
|
|
63
80
|
/** Is the client mid-hydration right now? */
|
|
64
81
|
export function isHydrating() {
|
|
65
82
|
return _hydrating;
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Run `fn` with the hydration flag *cleared*, restoring the prior value (nesting-safe).
|
|
87
|
+
*
|
|
88
|
+
* The build/adopt decision hangs on one module-global flag, but the invariant it must
|
|
89
|
+
* encode is narrower: `isHydrating()` may be true only while the DOM being processed is
|
|
90
|
+
* the server's. The moment a component builds fresh DOM — because its view isn't
|
|
91
|
+
* adoptable (`RebuildIfServerChildren`), or its adopt hit a mismatch and it's recovering,
|
|
92
|
+
* or it's a nested build — that fresh subtree is *not* server-rendered. Its child custom
|
|
93
|
+
* elements upgrade synchronously during `appendChild` inside this build, and if they still
|
|
94
|
+
* saw `isHydrating()` true they'd try to *adopt* content their parent just `createElement`'d
|
|
95
|
+
* → a guaranteed `HydrationMismatch` cascade (a non-adoptable layout would tear the whole
|
|
96
|
+
* page's islands into rebuild-storms). Bracketing every build path with this makes the
|
|
97
|
+
* children build too, matching the DOM they're actually handed. Synchronous only — builds
|
|
98
|
+
* never await — so a plain global save/restore is correct even when builds nest.
|
|
99
|
+
*/
|
|
100
|
+
export function runBuild(fn) {
|
|
101
|
+
const prev = _hydrating;
|
|
102
|
+
_hydrating = false;
|
|
103
|
+
try {
|
|
104
|
+
return fn();
|
|
105
|
+
} finally {
|
|
106
|
+
_hydrating = prev;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
68
110
|
/** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe).
|
|
69
111
|
* Synchronous only — for the async first-paint pass use {@link beginHydration} /
|
|
70
112
|
* {@link endHydration}, which span the route module's `import()`. */
|
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
|
+
}
|