@pyreon/router 0.3.1 → 0.5.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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"ffb5fd41-1","name":"loader.ts"},{"uid":"ffb5fd41-3","name":"match.ts"},{"uid":"ffb5fd41-5","name":"scroll.ts"},{"uid":"ffb5fd41-7","name":"types.ts"},{"uid":"ffb5fd41-9","name":"router.ts"},{"uid":"ffb5fd41-11","name":"components.tsx"},{"uid":"ffb5fd41-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"ffb5fd41-1":{"renderedLength":2855,"gzipLength":1243,"brotliLength":0,"metaUid":"ffb5fd41-0"},"ffb5fd41-3":{"renderedLength":10804,"gzipLength":3328,"brotliLength":0,"metaUid":"ffb5fd41-2"},"ffb5fd41-5":{"renderedLength":1367,"gzipLength":576,"brotliLength":0,"metaUid":"ffb5fd41-4"},"ffb5fd41-7":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"ffb5fd41-6"},"ffb5fd41-9":{"renderedLength":8965,"gzipLength":2657,"brotliLength":0,"metaUid":"ffb5fd41-8"},"ffb5fd41-11":{"renderedLength":6631,"gzipLength":2488,"brotliLength":0,"metaUid":"ffb5fd41-10"},"ffb5fd41-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"ffb5fd41-12"}},"nodeMetas":{"ffb5fd41-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"ffb5fd41-1"},"imported":[{"uid":"ffb5fd41-14"}],"importedBy":[{"uid":"ffb5fd41-12"},{"uid":"ffb5fd41-10"}]},"ffb5fd41-2":{"id":"/src/match.ts","moduleParts":{"index.js":"ffb5fd41-3"},"imported":[],"importedBy":[{"uid":"ffb5fd41-12"},{"uid":"ffb5fd41-8"}]},"ffb5fd41-4":{"id":"/src/scroll.ts","moduleParts":{"index.js":"ffb5fd41-5"},"imported":[],"importedBy":[{"uid":"ffb5fd41-8"}]},"ffb5fd41-6":{"id":"/src/types.ts","moduleParts":{"index.js":"ffb5fd41-7"},"imported":[],"importedBy":[{"uid":"ffb5fd41-12"},{"uid":"ffb5fd41-8"}]},"ffb5fd41-8":{"id":"/src/router.ts","moduleParts":{"index.js":"ffb5fd41-9"},"imported":[{"uid":"ffb5fd41-14"},{"uid":"ffb5fd41-15"},{"uid":"ffb5fd41-2"},{"uid":"ffb5fd41-4"},{"uid":"ffb5fd41-6"}],"importedBy":[{"uid":"ffb5fd41-12"},{"uid":"ffb5fd41-10"}]},"ffb5fd41-10":{"id":"/src/components.tsx","moduleParts":{"index.js":"ffb5fd41-11"},"imported":[{"uid":"ffb5fd41-14"},{"uid":"ffb5fd41-0"},{"uid":"ffb5fd41-8"}],"importedBy":[{"uid":"ffb5fd41-12"}]},"ffb5fd41-12":{"id":"/src/index.ts","moduleParts":{"index.js":"ffb5fd41-13"},"imported":[{"uid":"ffb5fd41-10"},{"uid":"ffb5fd41-0"},{"uid":"ffb5fd41-2"},{"uid":"ffb5fd41-8"},{"uid":"ffb5fd41-6"}],"importedBy":[],"isEntry":true},"ffb5fd41-14":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"ffb5fd41-10"},{"uid":"ffb5fd41-0"},{"uid":"ffb5fd41-8"}]},"ffb5fd41-15":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"ffb5fd41-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"216d545d-1","name":"loader.ts"},{"uid":"216d545d-3","name":"match.ts"},{"uid":"216d545d-5","name":"scroll.ts"},{"uid":"216d545d-7","name":"types.ts"},{"uid":"216d545d-9","name":"router.ts"},{"uid":"216d545d-11","name":"components.tsx"},{"uid":"216d545d-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"216d545d-1":{"renderedLength":2855,"gzipLength":1243,"brotliLength":0,"metaUid":"216d545d-0"},"216d545d-3":{"renderedLength":12203,"gzipLength":3691,"brotliLength":0,"metaUid":"216d545d-2"},"216d545d-5":{"renderedLength":1367,"gzipLength":576,"brotliLength":0,"metaUid":"216d545d-4"},"216d545d-7":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"216d545d-6"},"216d545d-9":{"renderedLength":16575,"gzipLength":4649,"brotliLength":0,"metaUid":"216d545d-8"},"216d545d-11":{"renderedLength":6669,"gzipLength":2506,"brotliLength":0,"metaUid":"216d545d-10"},"216d545d-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"216d545d-12"}},"nodeMetas":{"216d545d-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"216d545d-1"},"imported":[{"uid":"216d545d-14"}],"importedBy":[{"uid":"216d545d-12"},{"uid":"216d545d-10"}]},"216d545d-2":{"id":"/src/match.ts","moduleParts":{"index.js":"216d545d-3"},"imported":[],"importedBy":[{"uid":"216d545d-12"},{"uid":"216d545d-8"}]},"216d545d-4":{"id":"/src/scroll.ts","moduleParts":{"index.js":"216d545d-5"},"imported":[],"importedBy":[{"uid":"216d545d-8"}]},"216d545d-6":{"id":"/src/types.ts","moduleParts":{"index.js":"216d545d-7"},"imported":[],"importedBy":[{"uid":"216d545d-12"},{"uid":"216d545d-8"}]},"216d545d-8":{"id":"/src/router.ts","moduleParts":{"index.js":"216d545d-9"},"imported":[{"uid":"216d545d-14"},{"uid":"216d545d-15"},{"uid":"216d545d-2"},{"uid":"216d545d-4"},{"uid":"216d545d-6"}],"importedBy":[{"uid":"216d545d-12"},{"uid":"216d545d-10"}]},"216d545d-10":{"id":"/src/components.tsx","moduleParts":{"index.js":"216d545d-11"},"imported":[{"uid":"216d545d-14"},{"uid":"216d545d-0"},{"uid":"216d545d-8"}],"importedBy":[{"uid":"216d545d-12"}]},"216d545d-12":{"id":"/src/index.ts","moduleParts":{"index.js":"216d545d-13"},"imported":[{"uid":"216d545d-10"},{"uid":"216d545d-0"},{"uid":"216d545d-2"},{"uid":"216d545d-8"},{"uid":"216d545d-6"}],"importedBy":[],"isEntry":true},"216d545d-14":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"216d545d-10"},{"uid":"216d545d-0"},{"uid":"216d545d-8"}]},"216d545d-15":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"216d545d-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -142,18 +142,28 @@ function compileSegment(raw) {
142
142
  raw,
143
143
  isParam: true,
144
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,
145
153
  paramName: raw.slice(1, -1)
146
154
  };
147
155
  if (raw.startsWith(":")) return {
148
156
  raw,
149
157
  isParam: true,
150
158
  isSplat: false,
159
+ isOptional: false,
151
160
  paramName: raw.slice(1)
152
161
  };
153
162
  return {
154
163
  raw,
155
164
  isParam: false,
156
165
  isSplat: false,
166
+ isOptional: false,
157
167
  paramName: ""
158
168
  };
159
169
  }
@@ -185,14 +195,30 @@ function compileRoute(route) {
185
195
  firstSegment
186
196
  };
187
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
+ }
188
212
  function compileRoutes(routes) {
189
213
  const cached = _compiledCache.get(routes);
190
214
  if (cached) return cached;
191
- const compiled = routes.map((r) => {
215
+ const compiled = [];
216
+ for (const r of routes) {
192
217
  const c = compileRoute(r);
193
218
  if (r.children && r.children.length > 0) c.children = compileRoutes(r.children);
194
- return c;
195
- });
219
+ compiled.push(c);
220
+ compiled.push(...expandAliases(r, c));
221
+ }
196
222
  _compiledCache.set(routes, compiled);
197
223
  return compiled;
198
224
  }
@@ -205,6 +231,9 @@ function getFirstSegment(segments) {
205
231
  /** Build a FlattenedRoute from segments + metadata */
206
232
  function makeFlatEntry(segments, chain, meta, isWildcard) {
207
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--;
208
237
  return {
209
238
  segments,
210
239
  segmentCount: segments.length,
@@ -214,7 +243,9 @@ function makeFlatEntry(segments, chain, meta, isWildcard) {
214
243
  meta,
215
244
  firstSegment: getFirstSegment(segments),
216
245
  hasSplat: segments.some((s) => s.isSplat),
217
- isWildcard
246
+ isWildcard,
247
+ hasOptional,
248
+ minSegments: minSegs
218
249
  };
219
250
  }
220
251
  /**
@@ -305,22 +336,31 @@ function captureSplat(pathParts, from, pathLen) {
305
336
  }
306
337
  return remaining.join("/");
307
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
+ }
308
346
  /** Try to match a flattened route against path parts */
309
347
  function matchFlattened(f, pathParts, pathLen) {
310
- if (f.segmentCount !== pathLen) {
311
- if (!f.hasSplat || pathLen < f.segmentCount) return null;
312
- }
348
+ if (!isSegmentCountCompatible(f, pathLen)) return null;
313
349
  const params = {};
314
350
  const segments = f.segments;
315
351
  const count = f.segmentCount;
316
352
  for (let i = 0; i < count; i++) {
317
353
  const seg = segments[i];
318
354
  const pt = pathParts[i];
319
- if (!seg || pt === void 0) return null;
355
+ if (!seg) return null;
320
356
  if (seg.isSplat) {
321
357
  params[seg.paramName] = captureSplat(pathParts, i, pathLen);
322
358
  return params;
323
359
  }
360
+ if (pt === void 0) {
361
+ if (!seg.isOptional) return null;
362
+ continue;
363
+ }
324
364
  if (seg.isParam) params[seg.paramName] = decodeSafe(pt);
325
365
  else if (seg.raw !== pt) return null;
326
366
  }
@@ -413,7 +453,11 @@ function mergeMeta(matched) {
413
453
  }
414
454
  /** Build a path string from a named route's pattern and params */
415
455
  function buildPath(pattern, params) {
416
- 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) => {
417
461
  const val = params[key] ?? "";
418
462
  if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
419
463
  return encodeURIComponent(val);
@@ -535,28 +579,134 @@ function useRoute() {
535
579
  if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
536
580
  return router.currentRoute;
537
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
+ }
538
687
  function createRouter(options) {
539
688
  const opts = Array.isArray(options) ? { routes: options } : options;
540
- 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 ?? "") : "";
541
691
  const nameIndex = buildNameIndex(routes);
542
692
  const guards = [];
543
693
  const afterHooks = [];
544
694
  const scrollManager = new ScrollManager(scrollBehavior);
545
695
  let _navGen = 0;
546
696
  const getInitialLocation = () => {
547
- if (opts.url) return opts.url;
697
+ if (opts.url) return stripBase(opts.url, base);
548
698
  if (!_isBrowser) return "/";
549
- if (mode === "history") return window.location.pathname + window.location.search;
699
+ if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
550
700
  const hash = window.location.hash;
551
701
  return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
552
702
  };
553
703
  const getCurrentLocation = () => {
554
704
  if (!_isBrowser) return currentPath();
555
- if (mode === "history") return window.location.pathname + window.location.search;
705
+ if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
556
706
  const hash = window.location.hash;
557
707
  return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
558
708
  };
559
- const currentPath = signal(getInitialLocation());
709
+ const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash));
560
710
  const currentRoute = computed(() => resolveRoute(currentPath(), routes));
561
711
  let _popstateHandler = null;
562
712
  let _hashchangeHandler = null;
@@ -612,7 +762,7 @@ function createRouter(options) {
612
762
  }
613
763
  function syncBrowserUrl(path, replace) {
614
764
  if (!_isBrowser) return;
615
- const url = mode === "history" ? path : `#${path}`;
765
+ const url = mode === "history" ? `${base}${path}` : `#${path}`;
616
766
  if (replace) window.history.replaceState(null, "", url);
617
767
  else window.history.pushState(null, "", url);
618
768
  }
@@ -628,27 +778,53 @@ function createRouter(options) {
628
778
  if (enterOutcome.action !== "continue") return enterOutcome;
629
779
  return runGlobalGuards(guards, to, from, gen);
630
780
  }
631
- async function runLoaders(to, gen, ac) {
632
- const loadableRecords = to.matched.filter((r) => r.loader);
633
- if (loadableRecords.length === 0) return true;
781
+ async function runBlockingLoaders(records, to, gen, ac) {
634
782
  const loaderCtx = {
635
783
  params: to.params,
636
784
  query: to.query,
637
785
  signal: ac.signal
638
786
  };
639
- const results = await Promise.allSettled(loadableRecords.map((r) => {
640
- if (!r.loader) return Promise.resolve(void 0);
641
- return r.loader(loaderCtx);
642
- }));
787
+ const results = await Promise.allSettled(records.map((r) => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
643
788
  if (gen !== _navGen) return false;
644
- for (let i = 0; i < loadableRecords.length; i++) {
789
+ for (let i = 0; i < records.length; i++) {
645
790
  const result = results[i];
646
- const record = loadableRecords[i];
791
+ const record = records[i];
647
792
  if (!result || !record) continue;
648
793
  if (!processLoaderResult(result, record, ac, to)) return false;
649
794
  }
650
795
  return true;
651
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
+ }
652
828
  function commitNavigation(path, replace, to, from) {
653
829
  scrollManager.save(from.path);
654
830
  currentPath.set(path);
@@ -660,8 +836,16 @@ function createRouter(options) {
660
836
  } catch (_err) {}
661
837
  if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
662
838
  }
663
- 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) {
664
847
  if (redirectDepth > 10) return;
848
+ const path = normalizeTrailingSlash(rawPath, trailingSlash);
665
849
  const gen = ++_navGen;
666
850
  loadingSignal.update((n) => n + 1);
667
851
  const to = resolveRoute(path, routes);
@@ -671,6 +855,10 @@ function createRouter(options) {
671
855
  loadingSignal.update((n) => n - 1);
672
856
  return navigate(redirectTarget, replace, redirectDepth + 1);
673
857
  }
858
+ if (await checkBlockers(to, from, gen) !== "continue") {
859
+ loadingSignal.update((n) => n - 1);
860
+ return;
861
+ }
674
862
  const guardOutcome = await runAllGuards(to, from, gen);
675
863
  if (guardOutcome.action !== "continue") {
676
864
  loadingSignal.update((n) => n - 1);
@@ -687,9 +875,14 @@ function createRouter(options) {
687
875
  commitNavigation(path, replace, to, from);
688
876
  loadingSignal.update((n) => n - 1);
689
877
  }
878
+ let _readyResolve = null;
879
+ const _readyPromise = new Promise((resolve) => {
880
+ _readyResolve = resolve;
881
+ });
690
882
  const router = {
691
883
  routes,
692
884
  mode,
885
+ _base: base,
693
886
  currentRoute,
694
887
  _currentPath: currentPath,
695
888
  _currentRoute: currentRoute,
@@ -701,18 +894,28 @@ function createRouter(options) {
701
894
  _erroredChunks: /* @__PURE__ */ new Set(),
702
895
  _loaderData: /* @__PURE__ */ new Map(),
703
896
  _abortController: null,
897
+ _blockers: /* @__PURE__ */ new Set(),
898
+ _readyResolve,
899
+ _readyPromise,
704
900
  _onError: onError,
705
901
  _maxCacheSize: maxCacheSize,
706
902
  async push(location) {
707
- if (typeof location === "string") return navigate(sanitizePath(location), false);
903
+ if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
708
904
  return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
709
905
  },
710
- async replace(path) {
711
- 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);
712
909
  },
713
910
  back() {
714
911
  if (_isBrowser) window.history.back();
715
912
  },
913
+ forward() {
914
+ if (_isBrowser) window.history.forward();
915
+ },
916
+ go(delta) {
917
+ if (_isBrowser) window.history.go(delta);
918
+ },
716
919
  beforeEach(guard) {
717
920
  guards.push(guard);
718
921
  return () => {
@@ -728,6 +931,9 @@ function createRouter(options) {
728
931
  };
729
932
  },
730
933
  loading: () => loadingSignal() > 0,
934
+ isReady() {
935
+ return router._readyPromise;
936
+ },
731
937
  destroy() {
732
938
  if (_popstateHandler) {
733
939
  window.removeEventListener("popstate", _popstateHandler);
@@ -739,6 +945,7 @@ function createRouter(options) {
739
945
  }
740
946
  guards.length = 0;
741
947
  afterHooks.length = 0;
948
+ router._blockers.clear();
742
949
  componentCache.clear();
743
950
  router._loaderData.clear();
744
951
  router._abortController?.abort();
@@ -746,6 +953,12 @@ function createRouter(options) {
746
953
  },
747
954
  _resolve: (rawPath) => resolveRoute(rawPath, routes)
748
955
  };
956
+ queueMicrotask(() => {
957
+ if (router._readyResolve) {
958
+ router._readyResolve();
959
+ router._readyResolve = null;
960
+ }
961
+ });
749
962
  return router;
750
963
  }
751
964
  async function runGuard(guard, to, from) {
@@ -763,6 +976,45 @@ function resolveNamedPath(name, params, query, index) {
763
976
  if (qs) path += `?${qs}`;
764
977
  return path;
765
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
+ }
766
1018
  /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
767
1019
  function sanitizePath(path) {
768
1020
  const trimmed = path.trim();
@@ -846,7 +1098,8 @@ const RouterLink = (props) => {
846
1098
  if (prefetchMode !== "hover" || !router) return;
847
1099
  prefetchRoute(router, props.to);
848
1100
  };
849
- 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}`;
850
1103
  const activeClass = () => {
851
1104
  if (!router) return "";
852
1105
  const current = router.currentRoute().path;
@@ -968,5 +1221,5 @@ function isStaleChunk(err) {
968
1221
  }
969
1222
 
970
1223
  //#endregion
971
- 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 };
972
1225
  //# sourceMappingURL=index.js.map