@pyreon/router 0.3.0 → 0.4.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/lib/index.js CHANGED
@@ -135,69 +135,253 @@ function stringifyQuery(query) {
135
135
  for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
136
136
  return parts.length ? `?${parts.join("&")}` : "";
137
137
  }
138
+ /** WeakMap cache: compile each RouteRecord[] once */
139
+ const _compiledCache = /* @__PURE__ */ new WeakMap();
140
+ function compileSegment(raw) {
141
+ if (raw.endsWith("*") && raw.startsWith(":")) return {
142
+ raw,
143
+ isParam: true,
144
+ isSplat: true,
145
+ isOptional: false,
146
+ paramName: raw.slice(1, -1)
147
+ };
148
+ if (raw.endsWith("?") && raw.startsWith(":")) return {
149
+ raw,
150
+ isParam: true,
151
+ isSplat: false,
152
+ isOptional: true,
153
+ paramName: raw.slice(1, -1)
154
+ };
155
+ if (raw.startsWith(":")) return {
156
+ raw,
157
+ isParam: true,
158
+ isSplat: false,
159
+ isOptional: false,
160
+ paramName: raw.slice(1)
161
+ };
162
+ return {
163
+ raw,
164
+ isParam: false,
165
+ isSplat: false,
166
+ isOptional: false,
167
+ paramName: ""
168
+ };
169
+ }
170
+ function compileRoute(route) {
171
+ const pattern = route.path;
172
+ if (pattern === "(.*)" || pattern === "*") return {
173
+ route,
174
+ isWildcard: true,
175
+ segments: [],
176
+ segmentCount: 0,
177
+ isStatic: false,
178
+ staticPath: null,
179
+ children: null,
180
+ firstSegment: null
181
+ };
182
+ const segments = pattern.split("/").filter(Boolean).map(compileSegment);
183
+ const isStatic = segments.every((s) => !s.isParam);
184
+ const staticPath = isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null;
185
+ const first = segments.length > 0 ? segments[0] : void 0;
186
+ const firstSegment = first && !first.isParam ? first.raw : null;
187
+ return {
188
+ route,
189
+ isWildcard: false,
190
+ segments,
191
+ segmentCount: segments.length,
192
+ isStatic,
193
+ staticPath,
194
+ children: null,
195
+ firstSegment
196
+ };
197
+ }
198
+ /** Expand alias paths into additional compiled entries sharing the original RouteRecord */
199
+ function expandAliases(r, c) {
200
+ if (!r.alias) return [];
201
+ return (Array.isArray(r.alias) ? r.alias : [r.alias]).map((aliasPath) => {
202
+ const { alias: _, ...withoutAlias } = r;
203
+ const ac = compileRoute({
204
+ ...withoutAlias,
205
+ path: aliasPath
206
+ });
207
+ ac.children = c.children;
208
+ ac.route = r;
209
+ return ac;
210
+ });
211
+ }
212
+ function compileRoutes(routes) {
213
+ const cached = _compiledCache.get(routes);
214
+ if (cached) return cached;
215
+ const compiled = [];
216
+ for (const r of routes) {
217
+ const c = compileRoute(r);
218
+ if (r.children && r.children.length > 0) c.children = compileRoutes(r.children);
219
+ compiled.push(c);
220
+ compiled.push(...expandAliases(r, c));
221
+ }
222
+ _compiledCache.set(routes, compiled);
223
+ return compiled;
224
+ }
225
+ /** Extract first static segment from a segment list, or null if dynamic/empty */
226
+ function getFirstSegment(segments) {
227
+ const first = segments[0];
228
+ if (first && !first.isParam) return first.raw;
229
+ return null;
230
+ }
231
+ /** Build a FlattenedRoute from segments + metadata */
232
+ function makeFlatEntry(segments, chain, meta, isWildcard) {
233
+ const isStatic = !isWildcard && segments.every((s) => !s.isParam);
234
+ const hasOptional = segments.some((s) => s.isOptional);
235
+ let minSegs = segments.length;
236
+ if (hasOptional) while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--;
237
+ return {
238
+ segments,
239
+ segmentCount: segments.length,
240
+ matchedChain: chain,
241
+ isStatic,
242
+ staticPath: isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null,
243
+ meta,
244
+ firstSegment: getFirstSegment(segments),
245
+ hasSplat: segments.some((s) => s.isSplat),
246
+ isWildcard,
247
+ hasOptional,
248
+ minSegments: minSegs
249
+ };
250
+ }
138
251
  /**
139
- * Match a single route pattern against a path segment.
140
- * Returns extracted params or null if no match.
141
- *
142
- * Supports:
143
- * - Exact segments: "/about"
144
- * - Param segments: "/user/:id"
145
- * - Wildcard: "(.*)" matches everything
252
+ * Flatten nested routes into leaf entries with pre-joined segments.
253
+ * This eliminates recursion during matching for the common case.
146
254
  */
147
- function matchPath(pattern, path) {
148
- if (pattern === "(.*)" || pattern === "*") return {};
149
- const patternParts = pattern.split("/").filter(Boolean);
150
- const pathParts = path.split("/").filter(Boolean);
255
+ function flattenRoutes(compiled) {
256
+ const result = [];
257
+ flattenWalk(result, compiled, [], [], {});
258
+ return result;
259
+ }
260
+ function flattenWalk(result, routes, parentSegments, parentChain, parentMeta) {
261
+ for (const c of routes) flattenOne(result, c, parentSegments, [...parentChain, c.route], c.route.meta ? {
262
+ ...parentMeta,
263
+ ...c.route.meta
264
+ } : { ...parentMeta });
265
+ }
266
+ function flattenOne(result, c, parentSegments, chain, meta) {
267
+ if (c.isWildcard) {
268
+ result.push(makeFlatEntry(parentSegments, chain, meta, true));
269
+ if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
270
+ return;
271
+ }
272
+ const joined = [...parentSegments, ...c.segments];
273
+ if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
274
+ result.push(makeFlatEntry(joined, chain, meta, false));
275
+ }
276
+ const _indexCache = /* @__PURE__ */ new WeakMap();
277
+ /** Classify a single flattened route into the appropriate index bucket */
278
+ function indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards) {
279
+ if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) staticMap.set(f.staticPath, f);
280
+ if (f.isWildcard) {
281
+ wildcards.push(f);
282
+ return;
283
+ }
284
+ if (f.segmentCount === 0) return;
285
+ if (f.firstSegment) {
286
+ let bucket = segmentMap.get(f.firstSegment);
287
+ if (!bucket) {
288
+ bucket = [];
289
+ segmentMap.set(f.firstSegment, bucket);
290
+ }
291
+ bucket.push(f);
292
+ } else dynamicFirst.push(f);
293
+ }
294
+ function buildRouteIndex(routes, compiled) {
295
+ const cached = _indexCache.get(routes);
296
+ if (cached) return cached;
297
+ const flattened = flattenRoutes(compiled);
298
+ const staticMap = /* @__PURE__ */ new Map();
299
+ const segmentMap = /* @__PURE__ */ new Map();
300
+ const dynamicFirst = [];
301
+ const wildcards = [];
302
+ for (const f of flattened) indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards);
303
+ const index = {
304
+ staticMap,
305
+ segmentMap,
306
+ dynamicFirst,
307
+ wildcards
308
+ };
309
+ _indexCache.set(routes, index);
310
+ return index;
311
+ }
312
+ /** Split path into segments without allocating a filtered array */
313
+ function splitPath(path) {
314
+ if (path === "/") return [];
315
+ const start = path.charCodeAt(0) === 47 ? 1 : 0;
316
+ const end = path.length;
317
+ if (start >= end) return [];
318
+ const parts = [];
319
+ let segStart = start;
320
+ for (let i = start; i <= end; i++) if (i === end || path.charCodeAt(i) === 47) {
321
+ if (i > segStart) parts.push(path.substring(segStart, i));
322
+ segStart = i + 1;
323
+ }
324
+ return parts;
325
+ }
326
+ /** Decode only if the segment contains a `%` character */
327
+ function decodeSafe(s) {
328
+ return s.indexOf("%") >= 0 ? decodeURIComponent(s) : s;
329
+ }
330
+ /** Collect remaining path segments as a decoded splat value */
331
+ function captureSplat(pathParts, from, pathLen) {
332
+ const remaining = [];
333
+ for (let j = from; j < pathLen; j++) {
334
+ const p = pathParts[j];
335
+ if (p !== void 0) remaining.push(decodeSafe(p));
336
+ }
337
+ return remaining.join("/");
338
+ }
339
+ /** Check whether a flattened route's segment count is compatible with the path length */
340
+ function isSegmentCountCompatible(f, pathLen) {
341
+ if (f.segmentCount === pathLen) return true;
342
+ if (f.hasSplat && pathLen >= f.segmentCount) return true;
343
+ if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true;
344
+ return false;
345
+ }
346
+ /** Try to match a flattened route against path parts */
347
+ function matchFlattened(f, pathParts, pathLen) {
348
+ if (!isSegmentCountCompatible(f, pathLen)) return null;
151
349
  const params = {};
152
- for (let i = 0; i < patternParts.length; i++) {
153
- const pp = patternParts[i];
350
+ const segments = f.segments;
351
+ const count = f.segmentCount;
352
+ for (let i = 0; i < count; i++) {
353
+ const seg = segments[i];
154
354
  const pt = pathParts[i];
155
- if (pp.endsWith("*") && pp.startsWith(":")) {
156
- const paramName = pp.slice(1, -1);
157
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
355
+ if (!seg) return null;
356
+ if (seg.isSplat) {
357
+ params[seg.paramName] = captureSplat(pathParts, i, pathLen);
158
358
  return params;
159
359
  }
160
- if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);
161
- else if (pp !== pt) return null;
360
+ if (pt === void 0) {
361
+ if (!seg.isOptional) return null;
362
+ continue;
363
+ }
364
+ if (seg.isParam) params[seg.paramName] = decodeSafe(pt);
365
+ else if (seg.raw !== pt) return null;
162
366
  }
163
- if (patternParts.length !== pathParts.length) return null;
164
367
  return params;
165
368
  }
166
- /**
167
- * Check if a path starts with a route's prefix (for nested route matching).
168
- * Returns the remaining path suffix, or null if no match.
169
- */
170
- function matchPrefix(pattern, path) {
171
- if (pattern === "(.*)" || pattern === "*") return {
172
- params: {},
173
- rest: path
174
- };
175
- const patternParts = pattern.split("/").filter(Boolean);
176
- const pathParts = path.split("/").filter(Boolean);
177
- if (pathParts.length < patternParts.length) return null;
178
- const params = {};
179
- for (let i = 0; i < patternParts.length; i++) {
180
- const pp = patternParts[i];
181
- const pt = pathParts[i];
182
- if (pp.endsWith("*") && pp.startsWith(":")) {
183
- const paramName = pp.slice(1, -1);
184
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
185
- return {
186
- params,
187
- rest: "/"
188
- };
189
- }
190
- if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);
191
- else if (pp !== pt) return null;
369
+ /** Search a list of flattened candidates for a match */
370
+ function searchCandidates(candidates, pathParts, pathLen) {
371
+ for (let i = 0; i < candidates.length; i++) {
372
+ const f = candidates[i];
373
+ if (!f) continue;
374
+ const params = matchFlattened(f, pathParts, pathLen);
375
+ if (params) return {
376
+ params,
377
+ matched: f.matchedChain
378
+ };
192
379
  }
193
- return {
194
- params,
195
- rest: `/${pathParts.slice(patternParts.length).join("/")}`
196
- };
380
+ return null;
197
381
  }
198
382
  /**
199
383
  * Resolve a raw path (including query string and hash) against the route tree.
200
- * Handles nested routes recursively.
384
+ * Uses flattened index for O(1) static lookup and first-segment dispatch.
201
385
  */
202
386
  function resolveRoute(rawPath, routes) {
203
387
  const qIdx = rawPath.indexOf("?");
@@ -207,14 +391,50 @@ function resolveRoute(rawPath, routes) {
207
391
  const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
208
392
  const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
209
393
  const query = parseQuery(queryPart);
210
- const match = matchRoutes(cleanPath, routes, []);
211
- if (match) return {
394
+ const index = buildRouteIndex(routes, compileRoutes(routes));
395
+ const staticMatch = index.staticMap.get(cleanPath);
396
+ if (staticMatch) return {
397
+ path: cleanPath,
398
+ params: {},
399
+ query,
400
+ hash,
401
+ matched: staticMatch.matchedChain,
402
+ meta: staticMatch.meta
403
+ };
404
+ const pathParts = splitPath(cleanPath);
405
+ const pathLen = pathParts.length;
406
+ if (pathLen > 0) {
407
+ const first = pathParts[0];
408
+ const bucket = index.segmentMap.get(first);
409
+ if (bucket) {
410
+ const match = searchCandidates(bucket, pathParts, pathLen);
411
+ if (match) return {
412
+ path: cleanPath,
413
+ params: match.params,
414
+ query,
415
+ hash,
416
+ matched: match.matched,
417
+ meta: mergeMeta(match.matched)
418
+ };
419
+ }
420
+ }
421
+ const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen);
422
+ if (dynMatch) return {
212
423
  path: cleanPath,
213
- params: match.params,
424
+ params: dynMatch.params,
425
+ query,
426
+ hash,
427
+ matched: dynMatch.matched,
428
+ meta: mergeMeta(dynMatch.matched)
429
+ };
430
+ const w = index.wildcards[0];
431
+ if (w) return {
432
+ path: cleanPath,
433
+ params: {},
214
434
  query,
215
435
  hash,
216
- matched: match.matched,
217
- meta: mergeMeta(match.matched)
436
+ matched: w.matchedChain,
437
+ meta: w.meta
218
438
  };
219
439
  return {
220
440
  path: cleanPath,
@@ -225,44 +445,6 @@ function resolveRoute(rawPath, routes) {
225
445
  meta: {}
226
446
  };
227
447
  }
228
- function matchRoutes(path, routes, parentMatched, parentParams = {}) {
229
- for (const route of routes) {
230
- const result = matchSingleRoute(path, route, parentMatched, parentParams);
231
- if (result) return result;
232
- }
233
- return null;
234
- }
235
- function matchSingleRoute(path, route, parentMatched, parentParams) {
236
- if (!route.children || route.children.length === 0) {
237
- const params = matchPath(route.path, path);
238
- if (params === null) return null;
239
- return {
240
- params: {
241
- ...parentParams,
242
- ...params
243
- },
244
- matched: [...parentMatched, route]
245
- };
246
- }
247
- const prefix = matchPrefix(route.path, path);
248
- if (prefix === null) return null;
249
- const allParams = {
250
- ...parentParams,
251
- ...prefix.params
252
- };
253
- const matched = [...parentMatched, route];
254
- const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams);
255
- if (childMatch) return childMatch;
256
- const exactParams = matchPath(route.path, path);
257
- if (exactParams === null) return null;
258
- return {
259
- params: {
260
- ...parentParams,
261
- ...exactParams
262
- },
263
- matched
264
- };
265
- }
266
448
  /** Merge meta from matched routes (leaf takes precedence) */
267
449
  function mergeMeta(matched) {
268
450
  const meta = {};
@@ -271,7 +453,11 @@ function mergeMeta(matched) {
271
453
  }
272
454
  /** Build a path string from a named route's pattern and params */
273
455
  function buildPath(pattern, params) {
274
- return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
456
+ return pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
457
+ const val = params[key];
458
+ if (!val) return "";
459
+ return `/${encodeURIComponent(val)}`;
460
+ }).replace(/:([^/]+)\*?/g, (match, key) => {
275
461
  const val = params[key] ?? "";
276
462
  if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
277
463
  return encodeURIComponent(val);
@@ -393,28 +579,134 @@ function useRoute() {
393
579
  if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
394
580
  return router.currentRoute;
395
581
  }
582
+ /**
583
+ * In-component guard: called before the component's route is left.
584
+ * Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
585
+ * Automatically removed on component unmount.
586
+ *
587
+ * @example
588
+ * onBeforeRouteLeave((to, from) => {
589
+ * if (hasUnsavedChanges()) return false
590
+ * })
591
+ */
592
+ function onBeforeRouteLeave(guard) {
593
+ const router = useContext(RouterContext) ?? _activeRouter;
594
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
595
+ const currentMatched = router.currentRoute().matched;
596
+ const wrappedGuard = (to, from) => {
597
+ if (!from.matched.some((r) => currentMatched.includes(r))) return void 0;
598
+ return guard(to, from);
599
+ };
600
+ const remove = router.beforeEach(wrappedGuard);
601
+ onUnmount(() => remove());
602
+ return remove;
603
+ }
604
+ /**
605
+ * In-component guard: called when the route changes but the component is reused
606
+ * (e.g. `/user/1` → `/user/2`). Useful for reacting to param changes.
607
+ * Automatically removed on component unmount.
608
+ *
609
+ * @example
610
+ * onBeforeRouteUpdate((to, from) => {
611
+ * if (!isValidId(to.params.id)) return false
612
+ * })
613
+ */
614
+ function onBeforeRouteUpdate(guard) {
615
+ const router = useContext(RouterContext) ?? _activeRouter;
616
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
617
+ const currentMatched = router.currentRoute().matched;
618
+ const wrappedGuard = (to, from) => {
619
+ if (!to.matched.some((r) => currentMatched.includes(r))) return void 0;
620
+ return guard(to, from);
621
+ };
622
+ const remove = router.beforeEach(wrappedGuard);
623
+ onUnmount(() => remove());
624
+ return remove;
625
+ }
626
+ /**
627
+ * Register a navigation blocker. The `fn` callback is called before each
628
+ * navigation — return `true` (or resolve to `true`) to block it.
629
+ *
630
+ * Automatically removed on component unmount if called during component setup.
631
+ * Also installs a `beforeunload` handler so the browser shows a confirmation
632
+ * dialog when the user tries to close the tab while a blocker is active.
633
+ *
634
+ * @example
635
+ * const blocker = useBlocker((to, from) => {
636
+ * return hasUnsavedChanges() && !confirm("Discard changes?")
637
+ * })
638
+ * // later: blocker.remove()
639
+ */
640
+ function useBlocker(fn) {
641
+ const router = useContext(RouterContext) ?? _activeRouter;
642
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
643
+ router._blockers.add(fn);
644
+ const beforeUnloadHandler = _isBrowser ? (e) => {
645
+ e.preventDefault();
646
+ } : null;
647
+ if (beforeUnloadHandler) window.addEventListener("beforeunload", beforeUnloadHandler);
648
+ const remove = () => {
649
+ router._blockers.delete(fn);
650
+ if (beforeUnloadHandler) window.removeEventListener("beforeunload", beforeUnloadHandler);
651
+ };
652
+ onUnmount(() => remove());
653
+ return { remove };
654
+ }
655
+ /**
656
+ * Reactive read/write access to the current route's query parameters.
657
+ *
658
+ * Returns `[get, set]` where `get` is a reactive signal producing the merged
659
+ * query object and `set` navigates to the current path with updated params.
660
+ *
661
+ * @example
662
+ * const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
663
+ * params().page // "1" if not in URL
664
+ * setParams({ page: "2" }) // navigates to ?page=2&sort=name
665
+ */
666
+ function useSearchParams(defaults) {
667
+ const router = useContext(RouterContext) ?? _activeRouter;
668
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
669
+ const get = () => {
670
+ const query = router.currentRoute().query;
671
+ if (!defaults) return query;
672
+ return {
673
+ ...defaults,
674
+ ...query
675
+ };
676
+ };
677
+ const set = (updates) => {
678
+ const merged = {
679
+ ...get(),
680
+ ...updates
681
+ };
682
+ const path = router.currentRoute().path + stringifyQuery(merged);
683
+ return router.replace(path);
684
+ };
685
+ return [get, set];
686
+ }
396
687
  function createRouter(options) {
397
688
  const opts = Array.isArray(options) ? { routes: options } : options;
398
- const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts;
689
+ const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100, trailingSlash = "strip" } = opts;
690
+ const base = mode === "history" ? normalizeBase(opts.base ?? "") : "";
399
691
  const nameIndex = buildNameIndex(routes);
400
692
  const guards = [];
401
693
  const afterHooks = [];
402
694
  const scrollManager = new ScrollManager(scrollBehavior);
403
695
  let _navGen = 0;
404
696
  const getInitialLocation = () => {
405
- if (opts.url) return opts.url;
697
+ if (opts.url) return stripBase(opts.url, base);
406
698
  if (!_isBrowser) return "/";
407
- if (mode === "history") return window.location.pathname + window.location.search;
699
+ if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
408
700
  const hash = window.location.hash;
409
701
  return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
410
702
  };
411
703
  const getCurrentLocation = () => {
412
704
  if (!_isBrowser) return currentPath();
413
- if (mode === "history") return window.location.pathname + window.location.search;
705
+ if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
414
706
  const hash = window.location.hash;
415
707
  return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
416
708
  };
417
- const currentPath = signal(getInitialLocation());
709
+ const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash));
418
710
  const currentRoute = computed(() => resolveRoute(currentPath(), routes));
419
711
  let _popstateHandler = null;
420
712
  let _hashchangeHandler = null;
@@ -470,7 +762,7 @@ function createRouter(options) {
470
762
  }
471
763
  function syncBrowserUrl(path, replace) {
472
764
  if (!_isBrowser) return;
473
- const url = mode === "history" ? path : `#${path}`;
765
+ const url = mode === "history" ? `${base}${path}` : `#${path}`;
474
766
  if (replace) window.history.replaceState(null, "", url);
475
767
  else window.history.pushState(null, "", url);
476
768
  }
@@ -486,27 +778,53 @@ function createRouter(options) {
486
778
  if (enterOutcome.action !== "continue") return enterOutcome;
487
779
  return runGlobalGuards(guards, to, from, gen);
488
780
  }
489
- async function runLoaders(to, gen, ac) {
490
- const loadableRecords = to.matched.filter((r) => r.loader);
491
- if (loadableRecords.length === 0) return true;
781
+ async function runBlockingLoaders(records, to, gen, ac) {
492
782
  const loaderCtx = {
493
783
  params: to.params,
494
784
  query: to.query,
495
785
  signal: ac.signal
496
786
  };
497
- const results = await Promise.allSettled(loadableRecords.map((r) => {
498
- if (!r.loader) return Promise.resolve(void 0);
499
- return r.loader(loaderCtx);
500
- }));
787
+ const results = await Promise.allSettled(records.map((r) => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
501
788
  if (gen !== _navGen) return false;
502
- for (let i = 0; i < loadableRecords.length; i++) {
789
+ for (let i = 0; i < records.length; i++) {
503
790
  const result = results[i];
504
- const record = loadableRecords[i];
791
+ const record = records[i];
505
792
  if (!result || !record) continue;
506
793
  if (!processLoaderResult(result, record, ac, to)) return false;
507
794
  }
508
795
  return true;
509
796
  }
797
+ /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
798
+ function revalidateSwrLoaders(records, to, ac) {
799
+ const loaderCtx = {
800
+ params: to.params,
801
+ query: to.query,
802
+ signal: ac.signal
803
+ };
804
+ for (const r of records) {
805
+ if (!r.loader) continue;
806
+ r.loader(loaderCtx).then((data) => {
807
+ if (!ac.signal.aborted) {
808
+ router._loaderData.set(r, data);
809
+ loadingSignal.update((n) => n + 1);
810
+ loadingSignal.update((n) => n - 1);
811
+ }
812
+ }).catch(() => {});
813
+ }
814
+ }
815
+ async function runLoaders(to, gen, ac) {
816
+ const loadableRecords = to.matched.filter((r) => r.loader);
817
+ if (loadableRecords.length === 0) return true;
818
+ const blocking = [];
819
+ const swr = [];
820
+ for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);
821
+ else blocking.push(r);
822
+ if (blocking.length > 0) {
823
+ if (!await runBlockingLoaders(blocking, to, gen, ac)) return false;
824
+ }
825
+ if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
826
+ return true;
827
+ }
510
828
  function commitNavigation(path, replace, to, from) {
511
829
  scrollManager.save(from.path);
512
830
  currentPath.set(path);
@@ -518,8 +836,16 @@ function createRouter(options) {
518
836
  } catch (_err) {}
519
837
  if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
520
838
  }
521
- async function navigate(path, replace, redirectDepth = 0) {
839
+ async function checkBlockers(to, from, gen) {
840
+ for (const blocker of router._blockers) {
841
+ const blocked = await blocker(to, from);
842
+ if (gen !== _navGen || blocked) return "cancel";
843
+ }
844
+ return "continue";
845
+ }
846
+ async function navigate(rawPath, replace, redirectDepth = 0) {
522
847
  if (redirectDepth > 10) return;
848
+ const path = normalizeTrailingSlash(rawPath, trailingSlash);
523
849
  const gen = ++_navGen;
524
850
  loadingSignal.update((n) => n + 1);
525
851
  const to = resolveRoute(path, routes);
@@ -529,6 +855,10 @@ function createRouter(options) {
529
855
  loadingSignal.update((n) => n - 1);
530
856
  return navigate(redirectTarget, replace, redirectDepth + 1);
531
857
  }
858
+ if (await checkBlockers(to, from, gen) !== "continue") {
859
+ loadingSignal.update((n) => n - 1);
860
+ return;
861
+ }
532
862
  const guardOutcome = await runAllGuards(to, from, gen);
533
863
  if (guardOutcome.action !== "continue") {
534
864
  loadingSignal.update((n) => n - 1);
@@ -545,9 +875,14 @@ function createRouter(options) {
545
875
  commitNavigation(path, replace, to, from);
546
876
  loadingSignal.update((n) => n - 1);
547
877
  }
878
+ let _readyResolve = null;
879
+ const _readyPromise = new Promise((resolve) => {
880
+ _readyResolve = resolve;
881
+ });
548
882
  const router = {
549
883
  routes,
550
884
  mode,
885
+ _base: base,
551
886
  currentRoute,
552
887
  _currentPath: currentPath,
553
888
  _currentRoute: currentRoute,
@@ -559,18 +894,28 @@ function createRouter(options) {
559
894
  _erroredChunks: /* @__PURE__ */ new Set(),
560
895
  _loaderData: /* @__PURE__ */ new Map(),
561
896
  _abortController: null,
897
+ _blockers: /* @__PURE__ */ new Set(),
898
+ _readyResolve,
899
+ _readyPromise,
562
900
  _onError: onError,
563
901
  _maxCacheSize: maxCacheSize,
564
902
  async push(location) {
565
- if (typeof location === "string") return navigate(sanitizePath(location), false);
903
+ if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
566
904
  return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
567
905
  },
568
- async replace(path) {
569
- return navigate(sanitizePath(path), true);
906
+ async replace(location) {
907
+ if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), true);
908
+ return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), true);
570
909
  },
571
910
  back() {
572
911
  if (_isBrowser) window.history.back();
573
912
  },
913
+ forward() {
914
+ if (_isBrowser) window.history.forward();
915
+ },
916
+ go(delta) {
917
+ if (_isBrowser) window.history.go(delta);
918
+ },
574
919
  beforeEach(guard) {
575
920
  guards.push(guard);
576
921
  return () => {
@@ -586,6 +931,9 @@ function createRouter(options) {
586
931
  };
587
932
  },
588
933
  loading: () => loadingSignal() > 0,
934
+ isReady() {
935
+ return router._readyPromise;
936
+ },
589
937
  destroy() {
590
938
  if (_popstateHandler) {
591
939
  window.removeEventListener("popstate", _popstateHandler);
@@ -597,6 +945,7 @@ function createRouter(options) {
597
945
  }
598
946
  guards.length = 0;
599
947
  afterHooks.length = 0;
948
+ router._blockers.clear();
600
949
  componentCache.clear();
601
950
  router._loaderData.clear();
602
951
  router._abortController?.abort();
@@ -604,6 +953,12 @@ function createRouter(options) {
604
953
  },
605
954
  _resolve: (rawPath) => resolveRoute(rawPath, routes)
606
955
  };
956
+ queueMicrotask(() => {
957
+ if (router._readyResolve) {
958
+ router._readyResolve();
959
+ router._readyResolve = null;
960
+ }
961
+ });
607
962
  return router;
608
963
  }
609
964
  async function runGuard(guard, to, from) {
@@ -621,6 +976,45 @@ function resolveNamedPath(name, params, query, index) {
621
976
  if (qs) path += `?${qs}`;
622
977
  return path;
623
978
  }
979
+ /** Normalize a base path: ensure leading `/`, strip trailing `/`. */
980
+ function normalizeBase(raw) {
981
+ if (!raw) return "";
982
+ let b = raw;
983
+ if (!b.startsWith("/")) b = `/${b}`;
984
+ if (b.endsWith("/")) b = b.slice(0, -1);
985
+ return b;
986
+ }
987
+ /** Strip the base prefix from a full URL path. Returns the app-relative path. */
988
+ function stripBase(path, base) {
989
+ if (!base) return path;
990
+ if (path === base || path === `${base}/`) return "/";
991
+ if (path.startsWith(`${base}/`)) return path.slice(base.length);
992
+ return path;
993
+ }
994
+ /** Normalize trailing slash on a path according to the configured strategy. */
995
+ function normalizeTrailingSlash(path, strategy) {
996
+ if (strategy === "ignore" || path === "/") return path;
997
+ const qIdx = path.indexOf("?");
998
+ const hIdx = path.indexOf("#");
999
+ const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length;
1000
+ const pathPart = path.slice(0, endIdx);
1001
+ const suffix = path.slice(endIdx);
1002
+ if (strategy === "strip") return pathPart.length > 1 && pathPart.endsWith("/") ? pathPart.slice(0, -1) + suffix : path;
1003
+ return !pathPart.endsWith("/") ? `${pathPart}/${suffix}` : path;
1004
+ }
1005
+ /**
1006
+ * Resolve a relative path (starting with `.` or `..`) against the current path.
1007
+ * Non-relative paths are returned as-is.
1008
+ */
1009
+ function resolveRelativePath(to, from) {
1010
+ if (!to.startsWith("./") && !to.startsWith("../") && to !== "." && to !== "..") return to;
1011
+ const fromSegments = from.split("/").filter(Boolean);
1012
+ fromSegments.pop();
1013
+ const toSegments = to.split("/").filter(Boolean);
1014
+ for (const seg of toSegments) if (seg === "..") fromSegments.pop();
1015
+ else if (seg !== ".") fromSegments.push(seg);
1016
+ return `/${fromSegments.join("/")}`;
1017
+ }
624
1018
  /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
625
1019
  function sanitizePath(path) {
626
1020
  const trimmed = path.trim();
@@ -704,7 +1098,8 @@ const RouterLink = (props) => {
704
1098
  if (prefetchMode !== "hover" || !router) return;
705
1099
  prefetchRoute(router, props.to);
706
1100
  };
707
- const href = router?.mode === "history" ? props.to : `#${props.to}`;
1101
+ const inst = router;
1102
+ const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
708
1103
  const activeClass = () => {
709
1104
  if (!router) return "";
710
1105
  const current = router.currentRoute().path;
@@ -826,5 +1221,5 @@ function isStaleChunk(err) {
826
1221
  }
827
1222
 
828
1223
  //#endregion
829
- export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
1224
+ export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
830
1225
  //# sourceMappingURL=index.js.map