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