@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 CHANGED
@@ -15,6 +15,8 @@ import { reportError } from "./errors.js";
15
15
 
16
16
  /** The consumer (effect or computed) currently executing, if any. */
17
17
  let activeConsumer = null;
18
+ /** Disposer collector of the innermost `scope()` currently building, if any. */
19
+ let activeScope = null;
18
20
  /** Depth of nested `batch()` calls; effects flush when this returns to 0. */
19
21
  let batchDepth = 0;
20
22
  /** Re-entrancy guard for the flush loop. */
@@ -149,6 +151,36 @@ export function computed(fn) {
149
151
  };
150
152
  }
151
153
 
154
+ /**
155
+ * Collect ownership of the effects created while `fn` runs. Returns
156
+ * `{ result, dispose }`: `dispose()` stops every effect `fn` created (including
157
+ * ones created in nested calls, unless an inner `scope()` claimed them first).
158
+ *
159
+ * This is how dynamic regions own their bindings: a keyed-list item, a
160
+ * conditional branch, or a mounted page builds its DOM inside a scope, and
161
+ * evicting/swapping/unmounting disposes the scope — otherwise the bindings'
162
+ * effects would stay subscribed to their signals forever (a leak that also
163
+ * makes every later write pay for dead subscribers).
164
+ */
165
+ export function scope(fn) {
166
+ const disposers = [];
167
+ const prev = activeScope;
168
+ activeScope = disposers;
169
+ let result;
170
+ try {
171
+ result = fn();
172
+ } finally {
173
+ activeScope = prev;
174
+ }
175
+ return {
176
+ result,
177
+ dispose() {
178
+ for (const d of disposers) d();
179
+ disposers.length = 0;
180
+ },
181
+ };
182
+ }
183
+
152
184
  /**
153
185
  * Run `fn` immediately, tracking the signals it reads, and re-run it whenever
154
186
  * any of them change. `fn` may return a cleanup function, which runs before the
@@ -166,22 +198,31 @@ export function effect(fn) {
166
198
  runCleanup(node);
167
199
  clearSources(node);
168
200
  const prev = activeConsumer;
201
+ const prevScope = activeScope;
202
+ // Effects created during a run belong to whoever the run's body says they
203
+ // do (an explicit inner scope()), never to whatever scope happens to be
204
+ // ambient — a flush-time re-run must not donate its children to an
205
+ // unrelated scope that was open when the write occurred.
169
206
  activeConsumer = node;
207
+ activeScope = null;
170
208
  try {
171
209
  const result = node.fn();
172
210
  if (typeof result === "function") node.cleanup = result;
173
211
  } finally {
174
212
  activeConsumer = prev;
213
+ activeScope = prevScope;
175
214
  }
176
215
  },
177
216
  };
178
217
  node.run();
179
- return function dispose() {
218
+ const dispose = function dispose() {
180
219
  if (node.disposed) return;
181
220
  node.disposed = true;
182
221
  runCleanup(node);
183
222
  clearSources(node);
184
223
  };
224
+ if (activeScope) activeScope.push(dispose);
225
+ return dispose;
185
226
  }
186
227
 
187
228
  function runCleanup(node) {
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@opentf/web",
3
- "version": "0.7.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": "./server/index.js"
10
+ "./server": {
11
+ "types": "./server/index.d.ts",
12
+ "default": "./server/index.js"
13
+ },
14
+ "./server/adapters/node": {
15
+ "types": "./server/adapters/node.d.ts",
16
+ "default": "./server/adapters/node.js"
17
+ }
11
18
  },
12
19
  "files": [
13
20
  "index.js",
package/runtime/dom.js CHANGED
@@ -2,7 +2,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
- const adopted = toNodes(adoptFn(cur));
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, adopted, true);
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
- function childEffect(anchor, fn, current, skipFirst) {
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
- return effect(() => {
286
- const next = toNodes(fn());
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
- return; // hydration: keep the adopted DOM on first paint (subscribe only)
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
- const node = adoptItem(cur, sig, index); // claims one item subtree off `cur`
337
- cache.set(key, { sig, node });
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
- return effect(() => {
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
- entry = { sig, node: renderItem(sig, index) };
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
  /**
@@ -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
- let _hydrating = false;
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
- const node = typeof view === "function" ? view() : view;
21
+ let node;
22
+ if (typeof view === "function") {
23
+ const s = scope(view);
24
+ node = s.result;
25
+ const lc = (node.__lifecycle ??= { mounts: [], cleanups: [] });
26
+ lc.cleanups.push(s.dispose);
27
+ } else {
28
+ node = view;
29
+ }
17
30
  target.appendChild(node);
18
31
  runMount(node);
19
32
  return node;
@@ -0,0 +1,139 @@
1
+ // `resource()` — the client-side async-data primitive (SPEC §7.4, docs/DATA.md).
2
+ // Components are strictly synchronous (SPEC §5.5), so async data lives *next to*
3
+ // the view: a resource wraps a fetcher in signals and the view renders its
4
+ // reactive `loading` / `error` / `data` states:
5
+ //
6
+ // const users = resource(() => fetch("/api/users").then((r) => r.json()));
7
+ // // in JSX: {users.loading ? <Spinner /> : users.data.map(...)}
8
+ //
9
+ // With a reactive source, the fetch re-runs whenever the source changes, and a
10
+ // `null`/`false` source pauses it (conditional fetching):
11
+ //
12
+ // const user = resource(() => router.params.id, (id, { signal }) =>
13
+ // fetch(`/api/users/${id}`, { signal }).then((r) => r.json()));
14
+ //
15
+ // Staleness: each run bumps a token and aborts the previous run's AbortController
16
+ // (passed to the fetcher as `{ signal }`), so an out-of-order resolution can never
17
+ // overwrite newer data — even for fetchers that ignore the signal.
18
+ //
19
+ // Server (SSG/SSR): no effect is created and nothing is fetched — `loading` stays
20
+ // `true`, so the prerendered HTML shows the loading branch. That is exactly what
21
+ // the client's first paint renders before its own fetch resolves, keeping
22
+ // hydration adoption aligned.
23
+
24
+ import { batch, effect, signal, untracked } from "../core/signals.js";
25
+
26
+ // Test override: bun tests run under happy-dom (a `document` always exists), so
27
+ // server behavior is opted into explicitly.
28
+ let serverOverride = null;
29
+
30
+ /** Force server (true) / client (false) behavior for new resources — tests only. */
31
+ export function __setResourceServer(v) {
32
+ serverOverride = v;
33
+ }
34
+
35
+ const isServer = () => serverOverride ?? typeof document === "undefined";
36
+
37
+ /**
38
+ * Create a resource. Two call shapes:
39
+ *
40
+ * resource(fetcher, options?) — fetch once (and on refetch())
41
+ * resource(source, fetcher, options?) — re-fetch when the reactive `source`
42
+ * changes; `null`/`false` pauses
43
+ *
44
+ * @param {Function} source reactive source read inside the tracking effect; its
45
+ * value is passed to the fetcher.
46
+ * @param {Function} fetcher `(sourceValue, { signal }) => data | Promise<data>`.
47
+ * @param {{ initial?: unknown }} [options] `initial` seeds `data` before the
48
+ * first resolution.
49
+ * @returns {{ data: unknown, loading: boolean, error: unknown,
50
+ * refetch: () => Promise<unknown> | undefined }} reactive getters —
51
+ * read them inside bindings/effects to subscribe.
52
+ */
53
+ export function resource(source, fetcher, options = {}) {
54
+ if (typeof fetcher !== "function") {
55
+ options = fetcher ?? {};
56
+ fetcher = source;
57
+ source = null;
58
+ }
59
+ const src = typeof source === "function" ? source : () => true;
60
+ const data = signal(options.initial);
61
+ const error = signal(undefined);
62
+ const loading = signal(true); // a fetch is intended; the server leaves it true
63
+ let token = 0;
64
+ let controller = null;
65
+
66
+ function run(value) {
67
+ controller?.abort();
68
+ // Captured per-run: by the time a superseded run's promise settles, the
69
+ // module-level `controller` already belongs to a newer run.
70
+ const c = (controller = typeof AbortController !== "undefined" ? new AbortController() : null);
71
+ const t = ++token;
72
+ batch(() => {
73
+ loading.value = true;
74
+ error.value = undefined;
75
+ });
76
+ // The async IIFE starts the fetcher *synchronously* (no wasted microtask)
77
+ // while converting a synchronous throw into a rejection.
78
+ return (async () => fetcher(value, { signal: c?.signal }))()
79
+ .then(
80
+ (v) => {
81
+ if (t !== token) return; // superseded — a newer run owns the signals
82
+ batch(() => {
83
+ data.value = v;
84
+ loading.value = false;
85
+ });
86
+ return v;
87
+ },
88
+ (e) => {
89
+ if (t !== token) return;
90
+ // Keep the last good `data` (stale-while-error); only `error` flips.
91
+ batch(() => {
92
+ error.value = e;
93
+ loading.value = false;
94
+ });
95
+ },
96
+ );
97
+ }
98
+
99
+ if (!isServer()) {
100
+ effect(() => {
101
+ const value = src(); // the effect's ONLY tracked read
102
+ if (value == null || value === false) {
103
+ // Paused: cancel anything in flight and settle as "not loading".
104
+ untracked(() => {
105
+ token++;
106
+ controller?.abort();
107
+ controller = null;
108
+ loading.value = false;
109
+ });
110
+ return;
111
+ }
112
+ untracked(() => run(value));
113
+ // Before a re-run (source changed) and on scope dispose: invalidate + abort,
114
+ // so a torn-down region's fetch can neither land nor leak a connection.
115
+ return () => {
116
+ token++;
117
+ controller?.abort();
118
+ };
119
+ });
120
+ }
121
+
122
+ return {
123
+ get data() {
124
+ return data.value;
125
+ },
126
+ get loading() {
127
+ return loading.value;
128
+ },
129
+ get error() {
130
+ return error.value;
131
+ },
132
+ /** Re-run the fetcher with the current source value (no-op while paused). */
133
+ refetch() {
134
+ const value = untracked(src);
135
+ if (value == null || value === false) return undefined;
136
+ return run(value);
137
+ },
138
+ };
139
+ }
@@ -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
+ }