@reactra/router 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/index.d.ts +508 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1049 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +162 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +158 -0
- package/dist/middleware.js.map +1 -0
- package/dist/portal.d.ts +35 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +49 -0
- package/dist/portal.js.map +1 -0
- package/dist/scrollManager.d.ts +69 -0
- package/dist/scrollManager.d.ts.map +1 -0
- package/dist/scrollManager.js +189 -0
- package/dist/scrollManager.js.map +1 -0
- package/package.json +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
// @reactra/router — Hash + History mode router + route-store lifecycle
|
|
2
|
+
//
|
|
3
|
+
// Owner: reactra-router-spec.md (Spec 3.2 / Discussion v4.3)
|
|
4
|
+
//
|
|
5
|
+
// `_middleware.ts` route-guard API (Wave 3, Stage 1) lives in ./middleware.ts
|
|
6
|
+
// and is re-exported below.
|
|
7
|
+
//
|
|
8
|
+
// Phase 1 surface (Day 10a + Day 13):
|
|
9
|
+
// RouterRegistry — pure core: routes map, pathname state, transitions
|
|
10
|
+
// configureRouter({ routes, mode }) — mode: "hash" (default) | "history"
|
|
11
|
+
// navigate(path) — push a new pathname, fire onExit/onEnter bindings
|
|
12
|
+
// useRoute() — React hook returning { pathname, params, query, route }
|
|
13
|
+
// RouteRenderer — renders the active route's component
|
|
14
|
+
//
|
|
15
|
+
// Day 13 (item #5b) adds `mode: "history"`. In history mode, navigate()
|
|
16
|
+
// uses `history.pushState` and the router listens for `popstate` to drive
|
|
17
|
+
// re-renders on back/forward. Hash mode (the Day-10a default) is
|
|
18
|
+
// unchanged. The pure core still works without a DOM — tests drive
|
|
19
|
+
// `navigate` directly under Node.
|
|
20
|
+
//
|
|
21
|
+
// Deferred to subsequent days (still tracked in phase-1.md):
|
|
22
|
+
// meta / prefetch / transition codegen (#5c)
|
|
23
|
+
// RouteLink, useQueryUpdater, useRouteMatch (#5c)
|
|
24
|
+
// Typed query coercion (number / boolean / enum) (#5c)
|
|
25
|
+
// _middleware, route guards, scroll restoration (#5d)
|
|
26
|
+
// `for subtree` and keyed route stores (#5c)
|
|
27
|
+
// <a href> click interception → navigate() (#5c)
|
|
28
|
+
import { Component, createElement, Suspense, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
29
|
+
import { __setMiddlewareServiceResolver, buildContextCommands, runAfterLeaveChain, runBeforeEnterChain, } from "./middleware.js";
|
|
30
|
+
import { createScrollManager, ensureHistoryKey, pushStateWithKey, readHistoryKey, replaceStateWithKey, } from "./scrollManager.js";
|
|
31
|
+
/**
|
|
32
|
+
* Match a pathname against a registered pattern. Returns the extracted
|
|
33
|
+
* params if it matches, `null` otherwise.
|
|
34
|
+
*
|
|
35
|
+
* Patterns use Express-style `:name` placeholders. Each segment is matched
|
|
36
|
+
* literally except for placeholders, which capture into `params[name]`.
|
|
37
|
+
* No catch-all (`*`) or optional segments in this MVP.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Match a pathname against a route pattern, returning the extracted params
|
|
41
|
+
* if it matches or `null` otherwise. Exposed for {@link useRouteMatch} and
|
|
42
|
+
* for app code that wants to test an arbitrary pattern against the current
|
|
43
|
+
* (or any) pathname.
|
|
44
|
+
*/
|
|
45
|
+
export const matchRoute = (pattern, pathname) => {
|
|
46
|
+
const patSeg = pattern.split("/").filter(Boolean);
|
|
47
|
+
const pathSeg = pathname.split("/").filter(Boolean);
|
|
48
|
+
const params = {};
|
|
49
|
+
for (let i = 0; i < patSeg.length; i++) {
|
|
50
|
+
const p = patSeg[i];
|
|
51
|
+
// Catch-all (Wave 3 §2b) — `*name` consumes all REMAINING path
|
|
52
|
+
// segments (at least one — empty catch-alls don't match; the parent
|
|
53
|
+
// `/foo` route handles that case). Stored as a slash-joined string
|
|
54
|
+
// under the param name; consumers split on `/` for array semantics.
|
|
55
|
+
// (Spec §2.2 documents `{ slug: string[] }`; we keep the params type
|
|
56
|
+
// `Record<string, string>` for back-compat across the existing
|
|
57
|
+
// surface and revisit the proper widening in a later sweep.)
|
|
58
|
+
if (p.startsWith("*")) {
|
|
59
|
+
// Catch-all must be the last segment of the pattern.
|
|
60
|
+
if (i !== patSeg.length - 1)
|
|
61
|
+
return null;
|
|
62
|
+
const remaining = pathSeg.slice(i);
|
|
63
|
+
if (remaining.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
params[p.slice(1)] = remaining.map((s) => decodeURIComponent(s)).join("/");
|
|
66
|
+
return params;
|
|
67
|
+
}
|
|
68
|
+
if (i >= pathSeg.length)
|
|
69
|
+
return null;
|
|
70
|
+
const s = pathSeg[i];
|
|
71
|
+
if (p.startsWith(":")) {
|
|
72
|
+
params[p.slice(1)] = decodeURIComponent(s);
|
|
73
|
+
}
|
|
74
|
+
else if (p !== s) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// No catch-all consumed the tail — the path must have exactly the
|
|
79
|
+
// pattern's segment count for a match.
|
|
80
|
+
if (patSeg.length !== pathSeg.length)
|
|
81
|
+
return null;
|
|
82
|
+
return params;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Parse a query string fragment ("?a=1&b=2" or "a=1&b=2") into a flat object.
|
|
86
|
+
* Repeated keys collapse to the last value — multi-value support and typed
|
|
87
|
+
* coercion are Day 10b.
|
|
88
|
+
*/
|
|
89
|
+
const parseQuery = (search) => {
|
|
90
|
+
const out = {};
|
|
91
|
+
const trimmed = search.startsWith("?") ? search.slice(1) : search;
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
return out;
|
|
94
|
+
// A repeated key (`?tag=x&tag=y`) accumulates into an array (Router §3.1 —
|
|
95
|
+
// backs `query foo: string[]`); a single occurrence stays a string. The
|
|
96
|
+
// coercion layer collapses an unexpected array to its last value for a scalar
|
|
97
|
+
// `query` field, so a single-value field is unaffected by a stray repeat.
|
|
98
|
+
const put = (k, v) => {
|
|
99
|
+
const existing = out[k];
|
|
100
|
+
if (existing === undefined)
|
|
101
|
+
out[k] = v;
|
|
102
|
+
else if (Array.isArray(existing))
|
|
103
|
+
existing.push(v);
|
|
104
|
+
else
|
|
105
|
+
out[k] = [existing, v];
|
|
106
|
+
};
|
|
107
|
+
for (const pair of trimmed.split("&")) {
|
|
108
|
+
if (!pair)
|
|
109
|
+
continue;
|
|
110
|
+
const eq = pair.indexOf("=");
|
|
111
|
+
if (eq < 0)
|
|
112
|
+
put(decodeURIComponent(pair), "");
|
|
113
|
+
else
|
|
114
|
+
put(decodeURIComponent(pair.slice(0, eq)), decodeURIComponent(pair.slice(eq + 1)));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Split a fully-qualified URL fragment into its pathname + raw query parts.
|
|
120
|
+
* Used by `navigate` to peel apart the input string.
|
|
121
|
+
*/
|
|
122
|
+
const splitPathQuery = (input) => {
|
|
123
|
+
const q = input.indexOf("?");
|
|
124
|
+
if (q < 0)
|
|
125
|
+
return { pathname: input, query: "" };
|
|
126
|
+
return { pathname: input.slice(0, q), query: input.slice(q + 1) };
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Build a concrete URL from a route PATTERN plus params + query — the
|
|
130
|
+
* runtime half of the typed-navigation surface (Router §5.3 / `#5c-typed`).
|
|
131
|
+
*
|
|
132
|
+
* The generated manifest's typed `navigate` (Router §5.1) delegates here:
|
|
133
|
+
* the compiler-derived `RouteId` / `RouteParams` types guarantee the
|
|
134
|
+
* caller passes the right param keys, and this function fills the
|
|
135
|
+
* `:name` placeholders with their (URL-encoded) values and appends a
|
|
136
|
+
* query string.
|
|
137
|
+
*
|
|
138
|
+
* buildPath("/", ) → "/"
|
|
139
|
+
* buildPath("/customers/:id", { id: "c 3" }) → "/customers/c%203"
|
|
140
|
+
* buildPath("/customers/:id", { id: "c3" }, { tab: "notes" })
|
|
141
|
+
* → "/customers/c3?tab=notes"
|
|
142
|
+
*
|
|
143
|
+
* Throws (RO002-shaped) when a `:name` segment has no matching param —
|
|
144
|
+
* surfaced as a runtime guard behind the compile-time type check, so a
|
|
145
|
+
* JS caller bypassing the types still gets a clear error rather than a
|
|
146
|
+
* literal `/customers/:id` URL.
|
|
147
|
+
*/
|
|
148
|
+
export const buildPath = (pattern, params, query) => {
|
|
149
|
+
const filled = pattern
|
|
150
|
+
.split("/")
|
|
151
|
+
.map((seg) => {
|
|
152
|
+
if (!seg.startsWith(":"))
|
|
153
|
+
return seg;
|
|
154
|
+
const name = seg.slice(1);
|
|
155
|
+
const value = params?.[name];
|
|
156
|
+
if (value === undefined) {
|
|
157
|
+
throw new Error(`[@reactra/router] RO002: missing param "${name}" for route ` +
|
|
158
|
+
`pattern "${pattern}". Pass it via navigate("${pattern}", ` +
|
|
159
|
+
`{ params: { ${name}: … } }). (Router §5.3.)`);
|
|
160
|
+
}
|
|
161
|
+
return encodeURIComponent(value);
|
|
162
|
+
})
|
|
163
|
+
.join("/");
|
|
164
|
+
if (query === undefined)
|
|
165
|
+
return filled;
|
|
166
|
+
const search = new URLSearchParams();
|
|
167
|
+
for (const [k, v] of Object.entries(query)) {
|
|
168
|
+
if (v === undefined)
|
|
169
|
+
continue;
|
|
170
|
+
// An array value emits a repeated key (`?tag=x&tag=y`) so it round-trips
|
|
171
|
+
// back to a `query foo: string[]` read (Router §3.1).
|
|
172
|
+
if (Array.isArray(v))
|
|
173
|
+
for (const item of v)
|
|
174
|
+
search.append(k, String(item));
|
|
175
|
+
else
|
|
176
|
+
search.set(k, String(v));
|
|
177
|
+
}
|
|
178
|
+
const qs = search.toString();
|
|
179
|
+
return qs.length > 0 ? `${filled}?${qs}` : filled;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Coerce one raw query-string value to its declared type at read time
|
|
183
|
+
* (Router §3.1 / §5.3). The compiler emits one of these per `query foo: T`
|
|
184
|
+
* read, threading the declared kind + the `= default` expression as
|
|
185
|
+
* `fallback`. Coercion is lenient — a malformed value (non-numeric for
|
|
186
|
+
* `number`, off-enum for an enum) yields the `fallback` rather than throwing,
|
|
187
|
+
* since the URL is an external, user-editable source (the spec's RO024/RO025
|
|
188
|
+
* are navigation-time checks; reads degrade gracefully). An absent value
|
|
189
|
+
* (`undefined`) always yields `fallback`.
|
|
190
|
+
*
|
|
191
|
+
* coerceQuery("42", "number") → 42
|
|
192
|
+
* coerceQuery("abc", "number", 1) → 1
|
|
193
|
+
* coerceQuery("true", "boolean") → true
|
|
194
|
+
* coerceQuery("x", ["a","b"], "a") → "a" (off-enum → fallback)
|
|
195
|
+
*/
|
|
196
|
+
export const coerceQuery = (raw, kind, fallback) => {
|
|
197
|
+
// `string[]` field: normalize to an array (a single value → one-element array;
|
|
198
|
+
// absent → the fallback, or [] when none). Repeated keys arrive here as arrays.
|
|
199
|
+
if (kind === "string[]") {
|
|
200
|
+
if (raw === undefined)
|
|
201
|
+
return fallback ?? [];
|
|
202
|
+
return Array.isArray(raw) ? [...raw] : [raw];
|
|
203
|
+
}
|
|
204
|
+
// Scalar field that received a repeated key → last-value-wins (the array form
|
|
205
|
+
// is only meaningful for a declared `string[]`). A `typeof` check (not
|
|
206
|
+
// Array.isArray) so TS reduces the union to `string | undefined` for the rest.
|
|
207
|
+
if (typeof raw !== "string" && raw !== undefined)
|
|
208
|
+
raw = raw[raw.length - 1];
|
|
209
|
+
if (raw === undefined)
|
|
210
|
+
return fallback;
|
|
211
|
+
if (kind === "string")
|
|
212
|
+
return raw;
|
|
213
|
+
if (kind === "number") {
|
|
214
|
+
const n = Number(raw);
|
|
215
|
+
return Number.isNaN(n) ? fallback : n;
|
|
216
|
+
}
|
|
217
|
+
if (kind === "boolean") {
|
|
218
|
+
if (raw === "true" || raw === "1" || raw === "")
|
|
219
|
+
return true;
|
|
220
|
+
if (raw === "false" || raw === "0")
|
|
221
|
+
return false;
|
|
222
|
+
return fallback;
|
|
223
|
+
}
|
|
224
|
+
// enum: kind is the array of allowed raw values.
|
|
225
|
+
return kind.includes(raw) ? raw : fallback;
|
|
226
|
+
};
|
|
227
|
+
class RouterRegistryImpl {
|
|
228
|
+
routes = [];
|
|
229
|
+
listeners = new Set();
|
|
230
|
+
currentPathname = "/";
|
|
231
|
+
currentQuery = "";
|
|
232
|
+
currentSnapshot = {
|
|
233
|
+
pathname: "/",
|
|
234
|
+
params: {},
|
|
235
|
+
query: {},
|
|
236
|
+
route: null,
|
|
237
|
+
};
|
|
238
|
+
previousRoute = null;
|
|
239
|
+
// Name-keyed bindings (Day 16 / #18b). Compiler-emitted modules call
|
|
240
|
+
// `registerRouteBindings("Foo", { onEnter, onExit })` at module-load /
|
|
241
|
+
// HMR-reload time; the lifecycle reads by name via the matched route's
|
|
242
|
+
// `bindingsName` field. After an HMR re-evaluation the new module
|
|
243
|
+
// overwrites the entry under the same name — so a route still
|
|
244
|
+
// matches its current onEnter/onExit even though the component
|
|
245
|
+
// identity may have changed.
|
|
246
|
+
routeBindings = new Map();
|
|
247
|
+
// True once the active route's `onEnter` has fired. With code-split routes
|
|
248
|
+
// (§5.2) the page chunk — and its sibling `registerRouteBindings(...)` call —
|
|
249
|
+
// loads AFTER `setLocation` runs, so `setLocation` can't fire `onEnter` yet
|
|
250
|
+
// (bindings absent). `registerRouteBindings` then fires a catch-up `onEnter`
|
|
251
|
+
// for the active route iff it hasn't been entered. The flag prevents a double
|
|
252
|
+
// enter: the eager path (setLocation fires it) and an HMR re-registration of an
|
|
253
|
+
// already-entered route must NOT re-instantiate the route store.
|
|
254
|
+
currentEntered = false;
|
|
255
|
+
// Wave 3, Stage 3 — `_middleware.ts` chains keyed by route path. Populated
|
|
256
|
+
// via `setMiddlewareChains` (called from `configureRouter` or directly).
|
|
257
|
+
// `Map` keeps the lookup O(1) and avoids accidental prototype-chain hits a
|
|
258
|
+
// plain object record would expose.
|
|
259
|
+
middlewareChains = new Map();
|
|
260
|
+
/** Register a route. Idempotent on `id` — re-registering replaces. */
|
|
261
|
+
register = (route) => {
|
|
262
|
+
const existing = this.routes.findIndex((r) => r.id === route.id);
|
|
263
|
+
if (existing >= 0)
|
|
264
|
+
this.routes[existing] = route;
|
|
265
|
+
else
|
|
266
|
+
this.routes.push(route);
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Day 27 / `#5c-followup`: replace the entire routes array (typically
|
|
270
|
+
* with the freshly-evaluated `newMod.ROUTES` from a HMR-accept
|
|
271
|
+
* callback on the generated route manifest). Re-runs `setLocation`
|
|
272
|
+
* for the current pathname so a removed-mid-session route flips to
|
|
273
|
+
* the null-route state, a renamed route picks up its new shape, and
|
|
274
|
+
* an added route is matchable on the very next render — all without
|
|
275
|
+
* a page reload.
|
|
276
|
+
*
|
|
277
|
+
* The route's `onExit` (if any) fires for the previously-matched
|
|
278
|
+
* route inside `setLocation`, mirroring a normal navigation. The
|
|
279
|
+
* `previousRoute` snapshot is then updated to whatever matches now
|
|
280
|
+
* under the new set; the next genuine `navigate()` will exit it
|
|
281
|
+
* cleanly.
|
|
282
|
+
*/
|
|
283
|
+
replaceRoutes = (newRoutes) => {
|
|
284
|
+
this.routes = [...newRoutes];
|
|
285
|
+
this.setLocation(this.currentPathname, this.currentQuery);
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Register route lifecycle bindings under a stable name. Compiler-
|
|
289
|
+
* emitted in the same module that exports the page component (see
|
|
290
|
+
* Pass 9 codegen). Module-level call — runs once on first load AND
|
|
291
|
+
* again on every HMR re-evaluation, overwriting the prior entry.
|
|
292
|
+
* Safe to call before / after `register(route)`; lookup happens at
|
|
293
|
+
* route-enter time which is after both have been called.
|
|
294
|
+
*/
|
|
295
|
+
registerRouteBindings = (name, bindings) => {
|
|
296
|
+
this.routeBindings.set(name, bindings);
|
|
297
|
+
// Catch-up for code-split routes (§5.2): if these bindings belong to the
|
|
298
|
+
// currently-active route and `setLocation` couldn't fire `onEnter` yet (the
|
|
299
|
+
// page chunk was still loading), fire it now with the active params/query.
|
|
300
|
+
// This runs during the lazy chunk's module-eval, BEFORE React renders the
|
|
301
|
+
// page body — so the route store is instantiated before `useReactraStore`.
|
|
302
|
+
// `!currentEntered` keeps the eager path and HMR re-registration from
|
|
303
|
+
// double-entering (an already-entered route's store is left intact).
|
|
304
|
+
if (!this.currentEntered && this.currentSnapshot.route?.bindingsName === name) {
|
|
305
|
+
bindings.onEnter({
|
|
306
|
+
pathname: this.currentSnapshot.pathname,
|
|
307
|
+
params: this.currentSnapshot.params,
|
|
308
|
+
query: this.currentSnapshot.query,
|
|
309
|
+
});
|
|
310
|
+
this.currentEntered = true;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
/** Read access for tests + setLocation lifecycle. */
|
|
314
|
+
lookupRouteBindings = (name) => this.routeBindings.get(name);
|
|
315
|
+
/**
|
|
316
|
+
* Find a registered route by its path pattern (e.g. `/customers/:id`) — the
|
|
317
|
+
* value a `RouteLink`'s `to` carries. Used by {@link PrefetchRuntime} to reach
|
|
318
|
+
* a route's `preload` thunk. Returns the first match or `undefined`.
|
|
319
|
+
*/
|
|
320
|
+
getRouteByPath = (path) => this.routes.find((r) => r.path === path);
|
|
321
|
+
/**
|
|
322
|
+
* Drive the registry to a new pathname + query. Fires the previous
|
|
323
|
+
* route's `onExit` (if any), updates the snapshot, fires the new
|
|
324
|
+
* route's `onEnter` (if any), then notifies React subscribers.
|
|
325
|
+
*/
|
|
326
|
+
setLocation = (pathname, query) => {
|
|
327
|
+
this.currentPathname = pathname;
|
|
328
|
+
this.currentQuery = query;
|
|
329
|
+
const parsedQuery = parseQuery(query);
|
|
330
|
+
let matched = null;
|
|
331
|
+
let params = {};
|
|
332
|
+
for (const r of this.routes) {
|
|
333
|
+
const m = matchRoute(r.path, pathname);
|
|
334
|
+
if (m) {
|
|
335
|
+
matched = r;
|
|
336
|
+
params = m;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Lifecycle: dispose the prior route's stores before instantiating
|
|
341
|
+
// the new route's. If we re-enter the same route with different
|
|
342
|
+
// params, dispose + re-instantiate so the store sees the new inputs
|
|
343
|
+
// (RT-05). Bindings are looked up by name via the matched route's
|
|
344
|
+
// `bindingsName` field (Day 16 / #18b).
|
|
345
|
+
const prevBindings = this.previousRoute?.bindingsName != null
|
|
346
|
+
? this.routeBindings.get(this.previousRoute.bindingsName)
|
|
347
|
+
: undefined;
|
|
348
|
+
// The leaving route's own coordinates (still in currentSnapshot until the
|
|
349
|
+
// reassignment below) key its preserved-state slot on exit (Store §6.4).
|
|
350
|
+
// Wave 3 §2b — also thread the route we're going TO (its coords, derived
|
|
351
|
+
// here BEFORE the snapshot is updated) so subtree-aware stores can decide
|
|
352
|
+
// whether to keep their instance alive. `null` when no matched route on
|
|
353
|
+
// the destination (404).
|
|
354
|
+
const toCtx = matched
|
|
355
|
+
? { pathname, params, query: parsedQuery }
|
|
356
|
+
: null;
|
|
357
|
+
if (prevBindings) {
|
|
358
|
+
prevBindings.onExit({
|
|
359
|
+
pathname: this.currentSnapshot.pathname,
|
|
360
|
+
params: this.currentSnapshot.params,
|
|
361
|
+
query: this.currentSnapshot.query,
|
|
362
|
+
}, toCtx);
|
|
363
|
+
}
|
|
364
|
+
const nextBindings = matched?.bindingsName != null
|
|
365
|
+
? this.routeBindings.get(matched.bindingsName)
|
|
366
|
+
: undefined;
|
|
367
|
+
// Snapshot must be set BEFORE the catch-up path can read it, and the
|
|
368
|
+
// current-entered flag reset for the newly-matched route.
|
|
369
|
+
this.previousRoute = matched;
|
|
370
|
+
this.currentSnapshot = { pathname, params, query: parsedQuery, route: matched };
|
|
371
|
+
if (nextBindings) {
|
|
372
|
+
nextBindings.onEnter({ pathname, params, query: parsedQuery });
|
|
373
|
+
this.currentEntered = true;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
// No bindings: either the route has no route store (nothing to enter), or
|
|
377
|
+
// its page chunk hasn't loaded yet (code-split, §5.2) — registerRouteBindings
|
|
378
|
+
// fires the catch-up onEnter once the chunk registers them.
|
|
379
|
+
this.currentEntered = false;
|
|
380
|
+
}
|
|
381
|
+
// Opt-in replay route tap (Replay §5) — a no-op `?.` until the replay
|
|
382
|
+
// receiver installs it. No router→replay dependency.
|
|
383
|
+
globalThis.__REACTRA_REPLAY_ROUTE__?.({
|
|
384
|
+
pathname,
|
|
385
|
+
search: this.currentQuery,
|
|
386
|
+
params,
|
|
387
|
+
query: parsedQuery,
|
|
388
|
+
});
|
|
389
|
+
for (const l of this.listeners)
|
|
390
|
+
l();
|
|
391
|
+
};
|
|
392
|
+
/** Subscribe to route changes. useSyncExternalStore feeds React this. */
|
|
393
|
+
subscribe = (onChange) => {
|
|
394
|
+
this.listeners.add(onChange);
|
|
395
|
+
return () => this.listeners.delete(onChange);
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Install the middleware `ctx.service` resolver (Middleware spec v2 §3).
|
|
399
|
+
* Called by `@reactra/service`'s `bindScopedServicesToRouter` through the
|
|
400
|
+
* structural `RouterLike` contract — never by app code. Internal tier
|
|
401
|
+
* (Runtime v1 §1); delegates to the middleware module's slot.
|
|
402
|
+
*/
|
|
403
|
+
__setMiddlewareServiceResolver = (resolver) => {
|
|
404
|
+
__setMiddlewareServiceResolver(resolver);
|
|
405
|
+
};
|
|
406
|
+
/** Current route snapshot — stable reference until the next transition. */
|
|
407
|
+
getSnapshot = () => this.currentSnapshot;
|
|
408
|
+
/** Read-only access for tests + the browser-sync helper. */
|
|
409
|
+
getCurrentPathname = () => this.currentPathname;
|
|
410
|
+
getCurrentQuery = () => this.currentQuery;
|
|
411
|
+
/**
|
|
412
|
+
* Register the per-route middleware chains emitted by the walker (Wave 3,
|
|
413
|
+
* Stage 2). Accepts either a `Map` or a plain record (the
|
|
414
|
+
* `router-middleware.generated.ts` shape). Overwrites the prior set — the
|
|
415
|
+
* generated file's HMR re-registration goes through here.
|
|
416
|
+
*/
|
|
417
|
+
setMiddlewareChains = (chains) => {
|
|
418
|
+
this.middlewareChains.clear();
|
|
419
|
+
if (chains instanceof Map) {
|
|
420
|
+
for (const [k, v] of chains)
|
|
421
|
+
this.middlewareChains.set(k, v);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
for (const [k, v] of Object.entries(chains))
|
|
425
|
+
this.middlewareChains.set(k, v);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
/**
|
|
429
|
+
* Read the registered chain for a route path, or `undefined` if none.
|
|
430
|
+
* Returning `undefined` (rather than `[]`) lets the caller short-circuit
|
|
431
|
+
* the async wrapper for the empty-chain fast path.
|
|
432
|
+
*/
|
|
433
|
+
getMiddlewareChain = (routePath) => this.middlewareChains.get(routePath);
|
|
434
|
+
/** All registered routes — used by `navigate` to match before running the chain. */
|
|
435
|
+
getRoutes = () => this.routes;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The process-wide router singleton. The compiler emits imports of this
|
|
439
|
+
* (via `useRoute`) and the codegen-emitted `routeBindings` reference it
|
|
440
|
+
* indirectly through the page component's lifecycle hooks.
|
|
441
|
+
*/
|
|
442
|
+
export const RouterRegistry = new RouterRegistryImpl();
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// PrefetchRuntime (Router §5.5 / §8.5) — chunk + data warming.
|
|
445
|
+
//
|
|
446
|
+
// `RouteLink` calls `warm(to, params, query)` on its trigger (hover / visible /
|
|
447
|
+
// mount) to fetch the destination route's code-split chunk AND pre-fill its
|
|
448
|
+
// route stores' resources (Resource v1 Stage E) ahead of navigation. `cancel`
|
|
449
|
+
// aborts the per-key AbortController so the data warmers stop fetching.
|
|
450
|
+
//
|
|
451
|
+
// The `inflight` set dedups concurrent triggers for the same destination
|
|
452
|
+
// (keyed by route + params + query, so two links to the same pattern with
|
|
453
|
+
// different params don't share a slot). A per-key AbortController in
|
|
454
|
+
// `warmControllers` is what `cancel` aborts to stop in-flight data warming
|
|
455
|
+
// (chunk imports themselves aren't abortable — harmless, browser-cached).
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
const prefetchKey = (to, params, query) => `${to}|${JSON.stringify(params ?? {})}|${JSON.stringify(query ?? {})}`;
|
|
458
|
+
class PrefetchRuntimeImpl {
|
|
459
|
+
inflight = new Set();
|
|
460
|
+
// Stage E (Resource v1 §8.5) — per-key AbortController. `cancel(...)` aborts
|
|
461
|
+
// it so destination `bindings.warm(ctx, signal)` data warmers stop fetching.
|
|
462
|
+
warmControllers = new Map();
|
|
463
|
+
/**
|
|
464
|
+
* Warm the destination route ahead of navigation. Fires the code-split
|
|
465
|
+
* chunk preload (if any) AND the compiler-emitted route-bindings warmer
|
|
466
|
+
* (Stage E) which pre-fills the resource cache via `warmResource`. No-op
|
|
467
|
+
* if the route is already warming this exact `(to, params, query)`. Both
|
|
468
|
+
* branches share the dedup key; both fire under a per-key AbortController
|
|
469
|
+
* that `cancel(...)` aborts on real navigation.
|
|
470
|
+
*/
|
|
471
|
+
warm = (to, params = {}, query = {}) => {
|
|
472
|
+
const route = RouterRegistry.getRouteByPath(to);
|
|
473
|
+
const hasChunk = route?.preload !== undefined;
|
|
474
|
+
const bindings = route?.bindingsName != null
|
|
475
|
+
? RouterRegistry.lookupRouteBindings(route.bindingsName)
|
|
476
|
+
: undefined;
|
|
477
|
+
const hasData = bindings?.warm !== undefined;
|
|
478
|
+
if (!hasChunk && !hasData)
|
|
479
|
+
return;
|
|
480
|
+
const key = prefetchKey(to, params, query);
|
|
481
|
+
if (this.inflight.has(key))
|
|
482
|
+
return;
|
|
483
|
+
this.inflight.add(key);
|
|
484
|
+
const ctrl = new AbortController();
|
|
485
|
+
this.warmControllers.set(key, ctrl);
|
|
486
|
+
const tasks = [];
|
|
487
|
+
if (hasChunk && route?.preload !== undefined) {
|
|
488
|
+
tasks.push(Promise.resolve(route.preload()).catch((e) => {
|
|
489
|
+
console.warn("[reactra:prefetch] chunk", to, e);
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
if (hasData && bindings?.warm !== undefined) {
|
|
493
|
+
const ctx = {
|
|
494
|
+
pathname: to,
|
|
495
|
+
params: params,
|
|
496
|
+
query: query,
|
|
497
|
+
};
|
|
498
|
+
tasks.push(bindings.warm(ctx, ctrl.signal).catch(() => { }));
|
|
499
|
+
}
|
|
500
|
+
void Promise.allSettled(tasks).finally(() => {
|
|
501
|
+
this.inflight.delete(key);
|
|
502
|
+
this.warmControllers.delete(key);
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
/**
|
|
506
|
+
* Clear the dedup slot for a specific destination (params+query given) or
|
|
507
|
+
* for every in-flight warmup of a route (params+query omitted — used on real
|
|
508
|
+
* navigation). Aborts the per-key controller so any in-flight data warmer
|
|
509
|
+
* stops fetching (chunk imports stay running — harmless and browser-cached).
|
|
510
|
+
*/
|
|
511
|
+
cancel = (to, params, query) => {
|
|
512
|
+
if (params === undefined && query === undefined) {
|
|
513
|
+
for (const key of [...this.inflight]) {
|
|
514
|
+
if (key.startsWith(`${to}|`)) {
|
|
515
|
+
this.warmControllers.get(key)?.abort();
|
|
516
|
+
this.warmControllers.delete(key);
|
|
517
|
+
this.inflight.delete(key);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const key = prefetchKey(to, params, query);
|
|
523
|
+
this.warmControllers.get(key)?.abort();
|
|
524
|
+
this.warmControllers.delete(key);
|
|
525
|
+
this.inflight.delete(key);
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/** Process-wide prefetch singleton (Router §8.5). */
|
|
529
|
+
export const PrefetchRuntime = new PrefetchRuntimeImpl();
|
|
530
|
+
// Module-level mode set by configureRouter, read by navigate. Hash by
|
|
531
|
+
// default — preserves the Day-10a contract for callers that don't pass
|
|
532
|
+
// a `mode`. The `routerMode` getter exists for tests; production callers
|
|
533
|
+
// should not need to read this.
|
|
534
|
+
let routerMode = "hash";
|
|
535
|
+
export const __getRouterMode = () => routerMode;
|
|
536
|
+
// Suspense fallback shown while a code-split route chunk loads (§5.2). Default
|
|
537
|
+
// null (blank during the brief chunk fetch). Per-route `_loading.tsx` boundaries
|
|
538
|
+
// are Wave 3; set a single app-wide fallback via `configureRouter({ loadingFallback })`.
|
|
539
|
+
let routerLoadingFallback = null;
|
|
540
|
+
// Wave 3, §2b — scroll restoration. Configured via `configureRouter({
|
|
541
|
+
// scrollRestoration })`; null until the app opts in. The navigate-
|
|
542
|
+
// integration site short-circuits when null so apps that don't configure
|
|
543
|
+
// it get the prior behaviour (no scroll management at all).
|
|
544
|
+
let scrollManager = null;
|
|
545
|
+
// Tracks the LEAVING entry's history key so the slow-path (post-middleware)
|
|
546
|
+
// commit can save scroll for the correct entry — by the time the async
|
|
547
|
+
// chain resolves, the browser may have moved on, so we snapshot at the
|
|
548
|
+
// navigate() boundary.
|
|
549
|
+
let lastKnownHistoryKey = null;
|
|
550
|
+
/**
|
|
551
|
+
* Bootstrap call — invoked from `src/main.tsx` per Runtime v0 §5. Registers
|
|
552
|
+
* every route the app cares about, then attaches to the browser's location
|
|
553
|
+
* (hash or History API depending on `mode`) if `window` is available. Order
|
|
554
|
+
* rules: `configureStores` must precede `configureRouter` (route stores
|
|
555
|
+
* depend on the registry).
|
|
556
|
+
*
|
|
557
|
+
* `mode`:
|
|
558
|
+
* - `"hash"` (default): URL fragment drives routing
|
|
559
|
+
* (`#/customers/1`). No server config needed; works on static hosts.
|
|
560
|
+
* - `"history"`: real paths (`/customers/1`) drive routing via the
|
|
561
|
+
* History API. The dev server / production host MUST serve the SPA
|
|
562
|
+
* fallback (index.html) for any non-asset path. Vite's default
|
|
563
|
+
* `appType: "spa"` does this automatically.
|
|
564
|
+
*/
|
|
565
|
+
export const configureRouter = (config) => {
|
|
566
|
+
for (const r of config.routes)
|
|
567
|
+
RouterRegistry.register(r);
|
|
568
|
+
routerMode = config.mode ?? "hash";
|
|
569
|
+
if (config.loadingFallback !== undefined)
|
|
570
|
+
routerLoadingFallback = config.loadingFallback;
|
|
571
|
+
if (config.middlewareChains !== undefined) {
|
|
572
|
+
RouterRegistry.setMiddlewareChains(config.middlewareChains);
|
|
573
|
+
}
|
|
574
|
+
if (config.scrollRestoration !== undefined) {
|
|
575
|
+
scrollManager = createScrollManager({
|
|
576
|
+
defaultMode: config.scrollRestoration.defaultMode ?? "auto",
|
|
577
|
+
scrollKey: config.scrollRestoration.scrollKey ?? "reactra:scroll",
|
|
578
|
+
});
|
|
579
|
+
// Disable the browser's native scroll restoration so our manager owns
|
|
580
|
+
// it end-to-end. Without this the browser would also restore on
|
|
581
|
+
// popstate, racing our explicit `scrollTo` call.
|
|
582
|
+
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
|
583
|
+
try {
|
|
584
|
+
window.history.scrollRestoration = "manual";
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// older Safari throws on assignment — non-fatal, browser default
|
|
588
|
+
// restoration still works.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Ensure the initial entry carries a key so subsequent navigations can
|
|
592
|
+
// save+restore against a stable id.
|
|
593
|
+
lastKnownHistoryKey = ensureHistoryKey();
|
|
594
|
+
}
|
|
595
|
+
// Attach to the browser only if we're in one. In SSR / Node tests, the
|
|
596
|
+
// pure core (drive via navigate(...)) is the API.
|
|
597
|
+
if (typeof window === "undefined")
|
|
598
|
+
return;
|
|
599
|
+
if (routerMode === "hash") {
|
|
600
|
+
const sync = () => {
|
|
601
|
+
// Before driving setLocation: save the leaving entry's scroll so the
|
|
602
|
+
// user gets it back if they hit forward later, then drive the change.
|
|
603
|
+
if (scrollManager && lastKnownHistoryKey !== null) {
|
|
604
|
+
scrollManager.saveCurrent(lastKnownHistoryKey);
|
|
605
|
+
}
|
|
606
|
+
const raw = window.location.hash.slice(1) || "/";
|
|
607
|
+
const { pathname, query } = splitPathQuery(raw);
|
|
608
|
+
RouterRegistry.setLocation(pathname, query);
|
|
609
|
+
// hashchange events don't carry a stable per-entry key (the URL
|
|
610
|
+
// fragment is the only persisted bit). Use the pathname+query as a
|
|
611
|
+
// best-effort key — same URL revisit shares position. Adequate for
|
|
612
|
+
// hash mode; history mode below uses the proper history-state key.
|
|
613
|
+
if (scrollManager) {
|
|
614
|
+
const fakeKey = pathname + (query ? "?" + query : "");
|
|
615
|
+
scrollManager.applyForNavigation(fakeKey, "pop");
|
|
616
|
+
lastKnownHistoryKey = fakeKey;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
window.addEventListener("hashchange", sync);
|
|
620
|
+
sync();
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
// History mode: popstate fires on browser back/forward; pushState
|
|
624
|
+
// does NOT fire popstate, so navigate() drives setLocation directly
|
|
625
|
+
// (see below).
|
|
626
|
+
const sync = (isInitial) => {
|
|
627
|
+
if (scrollManager && lastKnownHistoryKey !== null) {
|
|
628
|
+
scrollManager.saveCurrent(lastKnownHistoryKey);
|
|
629
|
+
}
|
|
630
|
+
const pathname = window.location.pathname || "/";
|
|
631
|
+
const search = window.location.search;
|
|
632
|
+
const query = search.startsWith("?") ? search.slice(1) : search;
|
|
633
|
+
RouterRegistry.setLocation(pathname, query);
|
|
634
|
+
if (scrollManager) {
|
|
635
|
+
const key = readHistoryKey() ?? ensureHistoryKey();
|
|
636
|
+
// Initial mount is a synthetic "pop" (the user arrived at this URL
|
|
637
|
+
// directly, possibly with a back-forward cache); restore if we have
|
|
638
|
+
// a saved entry, otherwise scroll-to-top. Subsequent popstates
|
|
639
|
+
// (back / forward) also use "pop". Push events come through
|
|
640
|
+
// `commitNavigation`, not this listener.
|
|
641
|
+
scrollManager.applyForNavigation(key, isInitial ? "pop" : "pop");
|
|
642
|
+
lastKnownHistoryKey = key;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
window.addEventListener("popstate", () => sync(false));
|
|
646
|
+
sync(true);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Commit a new pathname + query to the registry, including the browser-
|
|
651
|
+
* history bookkeeping appropriate to the configured mode. Extracted so
|
|
652
|
+
* both the empty-chain fast path AND the post-middleware slow path can
|
|
653
|
+
* share the same commit step.
|
|
654
|
+
*/
|
|
655
|
+
const commitNavigation = (to, replace, pathname, query) => {
|
|
656
|
+
if (typeof window === "undefined") {
|
|
657
|
+
RouterRegistry.setLocation(pathname, query);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Save the leaving entry's scroll position BEFORE the URL changes so the
|
|
661
|
+
// window.scrollX/Y readings reflect the right entry. No-op when
|
|
662
|
+
// scrollManager is null (app opted out of scroll restoration entirely).
|
|
663
|
+
if (scrollManager && lastKnownHistoryKey !== null) {
|
|
664
|
+
scrollManager.saveCurrent(lastKnownHistoryKey);
|
|
665
|
+
}
|
|
666
|
+
let newKey = null;
|
|
667
|
+
if (routerMode === "hash") {
|
|
668
|
+
// Hash mode: replaceState swaps the current entry; assigning
|
|
669
|
+
// location.hash always pushes. Honour `replace` by editing the
|
|
670
|
+
// existing entry's hash in place.
|
|
671
|
+
if (replace) {
|
|
672
|
+
if (scrollManager) {
|
|
673
|
+
newKey = replaceStateWithKey({}, `#${to}`);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
window.history.replaceState({}, "", `#${to}`);
|
|
677
|
+
}
|
|
678
|
+
RouterRegistry.setLocation(pathname, query);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
window.location.hash = to;
|
|
682
|
+
// The hashchange listener fires post-assignment; it handles
|
|
683
|
+
// setLocation + scroll. Skip the post-commit scroll branch below.
|
|
684
|
+
lastKnownHistoryKey = pathname + (query ? "?" + query : "");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
if (replace) {
|
|
690
|
+
if (scrollManager) {
|
|
691
|
+
newKey = replaceStateWithKey({}, to);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
window.history.replaceState({}, "", to);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
if (scrollManager) {
|
|
699
|
+
newKey = pushStateWithKey({}, to);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
window.history.pushState({}, "", to);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
RouterRegistry.setLocation(pathname, query);
|
|
706
|
+
}
|
|
707
|
+
// Apply mode-specific scroll behaviour after the registry commit. Push +
|
|
708
|
+
// replace both treat as "push" (no restore — the user just initiated a
|
|
709
|
+
// forward navigation). On `auto` mode this is scroll-to-top.
|
|
710
|
+
if (scrollManager) {
|
|
711
|
+
scrollManager.applyForNavigation(newKey, replace ? "replace" : "push");
|
|
712
|
+
lastKnownHistoryKey = newKey;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
/**
|
|
716
|
+
* Build the {@link MiddlewareContext} for a navigation. The `to` coordinate
|
|
717
|
+
* is the resolved target; `from` snapshots the current route's coordinates
|
|
718
|
+
* (or `null` when there's no prior route — initial navigation).
|
|
719
|
+
*/
|
|
720
|
+
const buildMiddlewareContextFor = (toRoutePath, toPathname, toParams, toQuery) => {
|
|
721
|
+
const cur = RouterRegistry.getSnapshot();
|
|
722
|
+
const from = cur.route
|
|
723
|
+
? {
|
|
724
|
+
pathname: cur.pathname,
|
|
725
|
+
params: cur.params,
|
|
726
|
+
query: cur.query,
|
|
727
|
+
meta: {},
|
|
728
|
+
routeId: cur.route.path,
|
|
729
|
+
}
|
|
730
|
+
: null;
|
|
731
|
+
return {
|
|
732
|
+
to: {
|
|
733
|
+
pathname: toPathname,
|
|
734
|
+
params: toParams,
|
|
735
|
+
query: toQuery,
|
|
736
|
+
meta: {},
|
|
737
|
+
routeId: toRoutePath,
|
|
738
|
+
},
|
|
739
|
+
from,
|
|
740
|
+
...buildContextCommands(),
|
|
741
|
+
};
|
|
742
|
+
};
|
|
743
|
+
/**
|
|
744
|
+
* Navigate to a new path. Three flow shapes per Wave 3, Stage 3:
|
|
745
|
+
*
|
|
746
|
+
* - **No matched route OR empty middleware chain (fast path).** Commit
|
|
747
|
+
* synchronously — preserves the existing behaviour for every route
|
|
748
|
+
* that doesn't have a `_middleware.ts` ancestor.
|
|
749
|
+
*
|
|
750
|
+
* - **Non-empty middleware chain.** Fire-and-forget: build the
|
|
751
|
+
* {@link MiddlewareContext}, run `runBeforeEnterChain` async, then:
|
|
752
|
+
* - `redirect` command → recursive `navigate(cmd.path, cmd.replace)`.
|
|
753
|
+
* - `abort` command → return without commit (current route stays).
|
|
754
|
+
* - allow → commit, then run the PREVIOUS route's
|
|
755
|
+
* `afterLeave` chain in reverse.
|
|
756
|
+
* The function returns `void` synchronously; tests should await an
|
|
757
|
+
* `await Promise.resolve()` tick to see the post-chain state.
|
|
758
|
+
*
|
|
759
|
+
* Transport per mode:
|
|
760
|
+
* - `hash`: `window.location.hash = to` (fires `hashchange` →
|
|
761
|
+
* `RouterRegistry.setLocation`).
|
|
762
|
+
* - `history`: `history.pushState(...)` then drives `setLocation`
|
|
763
|
+
* directly (pushState does NOT fire `popstate`).
|
|
764
|
+
*
|
|
765
|
+
* In pure Node (tests), `setLocation` is called regardless so the registry
|
|
766
|
+
* is observable without a DOM.
|
|
767
|
+
*
|
|
768
|
+
* **Limitation — popstate / hashchange (browser back/forward).** Those
|
|
769
|
+
* paths skip the middleware chain in this stage: by the time the event
|
|
770
|
+
* fires, the URL has already changed in the browser, and fighting that
|
|
771
|
+
* with a back-push is fragile. A follow-up will harden this by replacing
|
|
772
|
+
* the offending history entry on abort and chaining to the redirect
|
|
773
|
+
* target. The same applies to the initial-mount `sync()`.
|
|
774
|
+
*/
|
|
775
|
+
export const navigate = (to, replace = false) => {
|
|
776
|
+
const { pathname, query } = splitPathQuery(to);
|
|
777
|
+
// Match the target route synchronously to decide which flow to use.
|
|
778
|
+
let matched = null;
|
|
779
|
+
let params = {};
|
|
780
|
+
for (const r of RouterRegistry.getRoutes()) {
|
|
781
|
+
const m = matchRoute(r.path, pathname);
|
|
782
|
+
if (m) {
|
|
783
|
+
matched = r;
|
|
784
|
+
params = m;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const chain = matched
|
|
789
|
+
? RouterRegistry.getMiddlewareChain(matched.path)
|
|
790
|
+
: undefined;
|
|
791
|
+
if (!chain || chain.length === 0) {
|
|
792
|
+
// Fast path — preserves prior synchronous behaviour exactly.
|
|
793
|
+
commitNavigation(to, replace, pathname, query);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
// Slow path — async chain. Snapshot the LEAVING route's chain BEFORE the
|
|
797
|
+
// commit so afterLeave can run against it after the new route's onEnter.
|
|
798
|
+
const leavingRoute = RouterRegistry.getSnapshot().route;
|
|
799
|
+
const leavingChain = leavingRoute !== null
|
|
800
|
+
? RouterRegistry.getMiddlewareChain(leavingRoute.path) ?? []
|
|
801
|
+
: [];
|
|
802
|
+
void (async () => {
|
|
803
|
+
const ctx = buildMiddlewareContextFor(matched.path, pathname, params, parseQuery(query));
|
|
804
|
+
const cmd = await runBeforeEnterChain(chain, ctx);
|
|
805
|
+
if (cmd?.type === "redirect") {
|
|
806
|
+
navigate(cmd.path, cmd.replace);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (cmd?.type === "abort") {
|
|
810
|
+
// No commit; current route stays. Spec §6.1 atomicity guarantee.
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Allowed — commit, then run leaving route's afterLeave chain in reverse.
|
|
814
|
+
commitNavigation(to, replace, pathname, query);
|
|
815
|
+
if (leavingChain.length > 0) {
|
|
816
|
+
// The afterLeave ctx still describes the SAME transition (to/from
|
|
817
|
+
// mirror the beforeEnter ctx). Errors are swallowed at the
|
|
818
|
+
// navigation boundary — a noisy afterLeave shouldn't corrupt the
|
|
819
|
+
// already-committed navigation.
|
|
820
|
+
try {
|
|
821
|
+
await runAfterLeaveChain(leavingChain, ctx);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
if (typeof console !== "undefined") {
|
|
825
|
+
console.error("[@reactra/router] afterLeave threw — swallowing post-commit", err);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
})();
|
|
830
|
+
};
|
|
831
|
+
/**
|
|
832
|
+
* React hook returning the active route state. Subscribes via
|
|
833
|
+
* `useSyncExternalStore` so a component re-renders when the route changes.
|
|
834
|
+
*/
|
|
835
|
+
export const useRoute = () => {
|
|
836
|
+
const subscribe = useCallback((onChange) => RouterRegistry.subscribe(onChange), []);
|
|
837
|
+
const getSnapshot = useCallback(() => RouterRegistry.getSnapshot(), []);
|
|
838
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
839
|
+
};
|
|
840
|
+
/**
|
|
841
|
+
* Merge `updates` into a current query object, returning a fresh plain object.
|
|
842
|
+
* Values are coerced to strings (the query bag is string-valued); a key set to
|
|
843
|
+
* `undefined` is REMOVED. Pure — the testable core of {@link useQueryUpdater}.
|
|
844
|
+
*/
|
|
845
|
+
export const mergeQuery = (current, updates) => {
|
|
846
|
+
const out = { ...current };
|
|
847
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
848
|
+
if (v === undefined)
|
|
849
|
+
delete out[k];
|
|
850
|
+
// Arrays carry through (a `string[]` query field round-trips via buildPath's
|
|
851
|
+
// repeated-key serialization); scalars stringify.
|
|
852
|
+
else if (Array.isArray(v))
|
|
853
|
+
out[k] = v.map(String);
|
|
854
|
+
else
|
|
855
|
+
out[k] = String(v);
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
};
|
|
859
|
+
/**
|
|
860
|
+
* React hook: does the current pathname match `pattern`? Returns the extracted
|
|
861
|
+
* params (`{}` for a param-less match) or `null` if it doesn't match. Useful
|
|
862
|
+
* for highlighting nav, conditional UI, or reading params for a pattern other
|
|
863
|
+
* than the active route. Subscribes to route changes via {@link useRoute}.
|
|
864
|
+
*/
|
|
865
|
+
export const useRouteMatch = (pattern) => {
|
|
866
|
+
const { pathname } = useRoute();
|
|
867
|
+
return matchRoute(pattern, pathname);
|
|
868
|
+
};
|
|
869
|
+
/**
|
|
870
|
+
* React hook returning a query-updater bound to the CURRENT route: call it with
|
|
871
|
+
* a partial `{ key: value }` to merge into the existing query and navigate to
|
|
872
|
+
* the same pathname with the new query string (a key set to `undefined` is
|
|
873
|
+
* removed). The URL stays the source of truth (Router §3.4 — query fields are
|
|
874
|
+
* read-only state sourced from the URL; to change one you navigate).
|
|
875
|
+
*
|
|
876
|
+
* const setQuery = useQueryUpdater()
|
|
877
|
+
* setQuery({ page: 2 }) // /customers → /customers?page=2
|
|
878
|
+
* setQuery({ page: undefined }, { replace: true }) // drop `page`, replace entry
|
|
879
|
+
*/
|
|
880
|
+
export const useQueryUpdater = () => {
|
|
881
|
+
const { pathname, query } = useRoute();
|
|
882
|
+
return useCallback((updates, options) => {
|
|
883
|
+
navigate(buildPath(pathname, undefined, mergeQuery(query, updates)), options?.replace ?? false);
|
|
884
|
+
}, [pathname, query]);
|
|
885
|
+
};
|
|
886
|
+
class RouteErrorBoundary extends Component {
|
|
887
|
+
state = { error: null };
|
|
888
|
+
static getDerivedStateFromError = (error) => ({ error });
|
|
889
|
+
reset = () => this.setState({ error: null });
|
|
890
|
+
render() {
|
|
891
|
+
if (this.state.error != null) {
|
|
892
|
+
return createElement(this.props.ErrorComp, { error: this.state.error, reset: this.reset });
|
|
893
|
+
}
|
|
894
|
+
return this.props.children;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Render the currently active route's component. Returns null when no route
|
|
899
|
+
* matches — Phase 1 demos can wrap with their own 404 layer. The route is
|
|
900
|
+
* composed (innermost → outermost) as:
|
|
901
|
+
* page → Suspense(loading) → layouts(innermost→outermost) → ErrorBoundary
|
|
902
|
+
* Layouts sit OUTSIDE the Suspense so they persist while the page area shows
|
|
903
|
+
* the loading fallback; the error boundary is outermost so it catches throws
|
|
904
|
+
* from any layout, the Suspense fallback, or the page (Router §2.3).
|
|
905
|
+
*/
|
|
906
|
+
export const RouteRenderer = () => {
|
|
907
|
+
const { route } = useRoute();
|
|
908
|
+
if (!route)
|
|
909
|
+
return null;
|
|
910
|
+
// Per-route `_loading.tsx` (§2.3, walker-resolved) wins over the global
|
|
911
|
+
// `configureRouter({ loadingFallback })`; without one, fall back to the
|
|
912
|
+
// app-wide default. Routes are code-split (`lazy()` in the manifest, §5.2),
|
|
913
|
+
// so the Suspense boundary catches the chunk-load suspension.
|
|
914
|
+
const fallback = route.loading ? createElement(route.loading) : routerLoadingFallback;
|
|
915
|
+
let content = createElement(Suspense, { fallback }, createElement(route.component));
|
|
916
|
+
// §2.3 `_layout.tsx` chain (outermost-first in the array). Wrap innermost
|
|
917
|
+
// first so each layout's `children` is the next-inner wrapper, ending with
|
|
918
|
+
// the page-in-Suspense at the bottom. Layouts are eagerly imported, so they
|
|
919
|
+
// render synchronously around a (suspending) page → typical "layout persists,
|
|
920
|
+
// content streams" pattern; only the page area swaps to the loading fallback.
|
|
921
|
+
if (route.layouts && route.layouts.length > 0) {
|
|
922
|
+
for (let i = route.layouts.length - 1; i >= 0; i--) {
|
|
923
|
+
content = createElement(route.layouts[i], null, content);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// §2.3 `_error.tsx` — outermost wrap; catches render-time throws from any
|
|
927
|
+
// layout, the Suspense fallback, or the page. Renders the user's component
|
|
928
|
+
// with `{ error, reset }` on a throw.
|
|
929
|
+
if (route.errorBoundary) {
|
|
930
|
+
content = createElement(RouteErrorBoundary, { ErrorComp: route.errorBoundary }, content);
|
|
931
|
+
}
|
|
932
|
+
return content;
|
|
933
|
+
};
|
|
934
|
+
/**
|
|
935
|
+
* Should a `RouteLink` click be intercepted for SPA navigation, or left to
|
|
936
|
+
* the browser? Standard SPA-link rules (Router §3.3): only a plain primary
|
|
937
|
+
* (left) click with no modifier keys, not already prevented, and not aimed
|
|
938
|
+
* at another browsing context (`target` other than `_self`). A modifier or
|
|
939
|
+
* middle click — "open in new tab" — falls through to native navigation.
|
|
940
|
+
*/
|
|
941
|
+
export const shouldInterceptClick = (e, target) => {
|
|
942
|
+
if (e.defaultPrevented)
|
|
943
|
+
return false;
|
|
944
|
+
if (e.button !== 0)
|
|
945
|
+
return false;
|
|
946
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
947
|
+
return false;
|
|
948
|
+
if (target !== undefined && target !== "" && target !== "_self")
|
|
949
|
+
return false;
|
|
950
|
+
return true;
|
|
951
|
+
};
|
|
952
|
+
/**
|
|
953
|
+
* Is `linkPath` the active route given the current pathname? Pure so it's
|
|
954
|
+
* unit-testable without the router. Exact match (or the root `/`, which would
|
|
955
|
+
* otherwise prefix-match everything) compares equality; otherwise a link is
|
|
956
|
+
* active for its own path and any descendant (`/customers` is active on
|
|
957
|
+
* `/customers/c1`).
|
|
958
|
+
*/
|
|
959
|
+
export const isRouteActive = (currentPath, linkPath, exactMatch = false) => {
|
|
960
|
+
if (exactMatch || linkPath === "/")
|
|
961
|
+
return currentPath === linkPath;
|
|
962
|
+
if (currentPath === linkPath)
|
|
963
|
+
return true;
|
|
964
|
+
const base = linkPath.endsWith("/") ? linkPath.slice(0, -1) : linkPath;
|
|
965
|
+
return currentPath.startsWith(base + "/");
|
|
966
|
+
};
|
|
967
|
+
/**
|
|
968
|
+
* A navigation anchor. Renders a real `<a href>` (so it is a normal,
|
|
969
|
+
* right-clickable, middle-clickable link with a visible URL) and intercepts
|
|
970
|
+
* plain left-clicks to drive SPA navigation via {@link navigate} instead of a
|
|
971
|
+
* full document load. Replaces the hand-rolled
|
|
972
|
+
* `<a onClick={e => { e.preventDefault(); navigate(...) }}>` pattern.
|
|
973
|
+
*
|
|
974
|
+
* `prefetch` (Router §5.5) warms the destination chunk ahead of click — on
|
|
975
|
+
* hover/focus, on viewport entry (IntersectionObserver), or on mount. Phase 1
|
|
976
|
+
* warms the code-split chunk only; resource/data warming lands with Resource v1.
|
|
977
|
+
*/
|
|
978
|
+
export const RouteLink = (props) => {
|
|
979
|
+
const href = buildPath(props.to, props.params, props.query);
|
|
980
|
+
const { pathname } = useRoute();
|
|
981
|
+
const linkPath = splitPathQuery(href).pathname;
|
|
982
|
+
const active = isRouteActive(pathname, linkPath, props.exactMatch ?? false);
|
|
983
|
+
const className = [props.className, active ? props.activeClass : undefined].filter(Boolean).join(" ") ||
|
|
984
|
+
undefined;
|
|
985
|
+
const anchorRef = useRef(null);
|
|
986
|
+
const { to, params, query } = props;
|
|
987
|
+
// Effective trigger: an explicit `prefetch` prop overrides; otherwise inherit
|
|
988
|
+
// the destination route's declared `prefetch on <trigger>` policy (§5.5). An
|
|
989
|
+
// explicit `prefetch="none"` opts out even if the page declares a trigger.
|
|
990
|
+
const prefetch = props.prefetch ?? RouterRegistry.getRouteByPath(to)?.prefetch?.trigger;
|
|
991
|
+
// `mount` + `visible` are effect-driven; `hover` is a handler (below). The
|
|
992
|
+
// serialized params/query in deps re-arm the observer when the destination
|
|
993
|
+
// changes. Cleanup cancels any pending warmup for this destination.
|
|
994
|
+
const paramsKey = JSON.stringify(params ?? {});
|
|
995
|
+
const queryKey = JSON.stringify(query ?? {});
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
if (!prefetch || prefetch === "none" || prefetch === "hover")
|
|
998
|
+
return;
|
|
999
|
+
if (prefetch === "mount") {
|
|
1000
|
+
PrefetchRuntime.warm(to, params, query);
|
|
1001
|
+
return () => PrefetchRuntime.cancel(to, params, query);
|
|
1002
|
+
}
|
|
1003
|
+
// "visible": warm when the link scrolls into view, then stop observing.
|
|
1004
|
+
const el = anchorRef.current;
|
|
1005
|
+
if (!el || typeof IntersectionObserver === "undefined")
|
|
1006
|
+
return;
|
|
1007
|
+
const obs = new IntersectionObserver((entries) => {
|
|
1008
|
+
for (const entry of entries) {
|
|
1009
|
+
if (entry.isIntersecting) {
|
|
1010
|
+
PrefetchRuntime.warm(to, params, query);
|
|
1011
|
+
obs.disconnect();
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
obs.observe(el);
|
|
1017
|
+
return () => {
|
|
1018
|
+
obs.disconnect();
|
|
1019
|
+
PrefetchRuntime.cancel(to, params, query);
|
|
1020
|
+
};
|
|
1021
|
+
// params/query are compared by their serialized form (objects are fresh each render).
|
|
1022
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1023
|
+
}, [to, prefetch, paramsKey, queryKey]);
|
|
1024
|
+
const onHoverWarm = prefetch === "hover" ? () => PrefetchRuntime.warm(to, params, query) : undefined;
|
|
1025
|
+
return createElement("a", {
|
|
1026
|
+
href,
|
|
1027
|
+
className,
|
|
1028
|
+
style: props.style,
|
|
1029
|
+
target: props.target,
|
|
1030
|
+
ref: anchorRef,
|
|
1031
|
+
onPointerEnter: onHoverWarm,
|
|
1032
|
+
onFocus: onHoverWarm,
|
|
1033
|
+
onClick: (e) => {
|
|
1034
|
+
if (!shouldInterceptClick(e, props.target))
|
|
1035
|
+
return;
|
|
1036
|
+
e.preventDefault();
|
|
1037
|
+
// Real navigation supersedes any in-flight warmup for this destination.
|
|
1038
|
+
PrefetchRuntime.cancel(to, params, query);
|
|
1039
|
+
navigate(href, props.replace ?? false);
|
|
1040
|
+
},
|
|
1041
|
+
}, props.children);
|
|
1042
|
+
};
|
|
1043
|
+
// ─── <Portal> — user-facing runtime component (createPortal wrapper) ────────
|
|
1044
|
+
export { Portal } from "./portal.js";
|
|
1045
|
+
// ─── `_middleware.ts` route-guard API — re-exports ─────────────────────────
|
|
1046
|
+
export { buildContextCommands, compose, defineMiddleware, isAbortCommand, isRedirectCommand, runAfterLeaveChain, runBeforeEnterChain, } from "./middleware.js";
|
|
1047
|
+
// ─── Scroll restoration — re-exports (Wave 3, §2b) ─────────────────────────
|
|
1048
|
+
export { createScrollManager, generateHistoryKey } from "./scrollManager.js";
|
|
1049
|
+
//# sourceMappingURL=index.js.map
|