@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/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