@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 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.8.0",
4
4
  "description": "The native-first OTF Web runtime — signal-based reactivity and zero-VDOM DOM operations, paired with the IR-based compiler.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
8
8
  "./signals": "./core/signals.js",
9
9
  "./runtime": "./runtime/index.js",
10
- "./server": "./server/index.js"
10
+ "./server": {
11
+ "types": "./server/index.d.ts",
12
+ "default": "./server/index.js"
13
+ },
14
+ "./server/adapters/node": {
15
+ "types": "./server/adapters/node.d.ts",
16
+ "default": "./server/adapters/node.js"
17
+ }
11
18
  },
12
19
  "files": [
13
20
  "index.js",
package/runtime/dom.js CHANGED
@@ -2,7 +2,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
- const adopted = toNodes(adoptFn(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)));
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, adopted, true);
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
- function childEffect(anchor, fn, current, skipFirst) {
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
- return effect(() => {
286
- const next = toNodes(fn());
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
- return; // hydration: keep the adopted DOM on first paint (subscribe only)
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
- const node = adoptItem(cur, sig, index); // claims one item subtree off `cur`
337
- cache.set(key, { sig, node });
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
- return effect(() => {
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
- entry = { sig, node: renderItem(sig, index) };
393
+ // Build inside a scope: the item owns its bindings' effects, so
394
+ // evicting it detaches them from shared signals (e.g. a selection
395
+ // signal every row's class reads). Without this every discarded row
396
+ // leaves a zombie effect that all later writes keep re-running.
397
+ const s = scope(() => renderItem(sig, index));
398
+ entry = { sig, node: s.result, dispose: s.dispose };
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
- 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
+ }
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
- .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
275
- .replace(/\[([^\]]+)\]/g, "(?<$1>[^/]+)");
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, swaps the rendered page
290
- * (tearing down the previous one's lifecycle), and updates window.history.
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.