@funstack/router 0.0.10 → 1.1.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.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  "use client";
2
-
3
- import { n as routeState, t as route } from "./route-p_gr5yPI.mjs";
4
- import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
2
+ import { n as route, r as routeState, t as bindRoute } from "./bindRoute-CQ2-ruTp.mjs";
3
+ import { createContext, useCallback, useContext, useEffect, useEffectEvent, useId, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
5
4
  import { jsx } from "react/jsx-runtime";
6
-
7
5
  //#region src/context/RouterContext.ts
8
6
  const RouterContext = createContext(null);
9
-
10
7
  //#endregion
11
8
  //#region src/context/BlockerContext.ts
12
9
  /**
@@ -29,7 +26,6 @@ function createBlockerRegistry() {
29
26
  };
30
27
  }
31
28
  const BlockerContext = createContext(null);
32
-
33
29
  //#endregion
34
30
  //#region src/types.ts
35
31
  /**
@@ -42,7 +38,6 @@ const BlockerContext = createContext(null);
42
38
  function internalRoutes(routes) {
43
39
  return routes;
44
40
  }
45
-
46
41
  //#endregion
47
42
  //#region src/core/matchRoutes.ts
48
43
  const SKIPPED = Symbol("skipped");
@@ -167,10 +162,42 @@ function matchPath(pattern, pathname, exact) {
167
162
  consumedPathname
168
163
  };
169
164
  }
170
-
165
+ //#endregion
166
+ //#region src/bypassInterception.ts
167
+ const bypassInterceptionSymbol = Symbol("bypassInterception");
168
+ /**
169
+ * Check if the given info is a bypass interception marker.
170
+ */
171
+ function isBypassInterception(info) {
172
+ return info === bypassInterceptionSymbol;
173
+ }
174
+ /**
175
+ * Perform a full page reload, bypassing the router's interception.
176
+ */
177
+ function hardReload() {
178
+ navigation.reload({ info: bypassInterceptionSymbol });
179
+ }
180
+ /**
181
+ * Navigate to the given URL with a full page navigation,
182
+ * bypassing the router's interception.
183
+ */
184
+ function hardNavigate(url) {
185
+ navigation.navigate(url, { info: bypassInterceptionSymbol });
186
+ }
171
187
  //#endregion
172
188
  //#region src/core/loaderCache.ts
173
189
  /**
190
+ * Wrapper for synchronous errors thrown by loaders.
191
+ * Cached instead of the raw error so the Router's useMemo doesn't throw.
192
+ * RouteRenderer checks for this class and re-throws the original error
193
+ * during rendering, where Error Boundaries can catch it.
194
+ */
195
+ var LoaderError = class {
196
+ constructor(error) {
197
+ this.error = error;
198
+ }
199
+ };
200
+ /**
174
201
  * Cache for loader results.
175
202
  * Key format: `${entryId}:${matchIndex}`
176
203
  */
@@ -182,7 +209,11 @@ const loaderCache = /* @__PURE__ */ new Map();
182
209
  function getOrCreateLoaderResult(entryId, matchIndex, route, args) {
183
210
  if (!route.loader) return;
184
211
  const cacheKey = `${entryId}:${matchIndex}`;
185
- if (!loaderCache.has(cacheKey)) loaderCache.set(cacheKey, route.loader(args));
212
+ if (!loaderCache.has(cacheKey)) try {
213
+ loaderCache.set(cacheKey, route.loader(args));
214
+ } catch (error) {
215
+ loaderCache.set(cacheKey, new LoaderError(error));
216
+ }
186
217
  return loaderCache.get(cacheKey);
187
218
  }
188
219
  /**
@@ -227,7 +258,6 @@ function clearLoaderCacheForEntry(entryId) {
227
258
  const prefix = `${entryId}:`;
228
259
  for (const key of loaderCache.keys()) if (key.startsWith(prefix)) loaderCache.delete(key);
229
260
  }
230
-
231
261
  //#endregion
232
262
  //#region src/core/NavigationAPIAdapter.ts
233
263
  /**
@@ -244,6 +274,7 @@ var NavigationAPIAdapter = class {
244
274
  #cachedSnapshot = null;
245
275
  #cachedEntryId = null;
246
276
  #currentNavigationInfo = void 0;
277
+ #reloadCounts = /* @__PURE__ */ new Map();
247
278
  getSnapshot() {
248
279
  const entry = navigation.currentEntry;
249
280
  if (!entry?.url) return null;
@@ -251,7 +282,9 @@ var NavigationAPIAdapter = class {
251
282
  this.#cachedEntryId = entry.id;
252
283
  this.#cachedSnapshot = {
253
284
  url: new URL(entry.url),
254
- key: entry.id,
285
+ key: this.#effectiveKey(entry.id),
286
+ entryId: entry.id,
287
+ entryKey: entry.key,
255
288
  state: entry.getState(),
256
289
  info: this.#currentNavigationInfo
257
290
  };
@@ -284,9 +317,19 @@ var NavigationAPIAdapter = class {
284
317
  entry.addEventListener("dispose", () => {
285
318
  clearLoaderCacheForEntry(entryId);
286
319
  this.#subscribedEntryIds.delete(entryId);
320
+ this.#reloadCounts.delete(entryId);
287
321
  }, { signal });
288
322
  }
289
323
  }
324
+ /**
325
+ * Compute the effective cache key for a given entry.
326
+ * Includes a reload suffix when the entry has been reloaded,
327
+ * so loaders get a fresh cache key and re-execute.
328
+ */
329
+ #effectiveKey(entryId) {
330
+ const count = this.#reloadCounts.get(entryId) ?? 0;
331
+ return count > 0 ? `${entryId}:r${count}` : entryId;
332
+ }
290
333
  navigate(to, options) {
291
334
  navigation.navigate(to, {
292
335
  history: options?.replace ? "replace" : "push",
@@ -301,8 +344,16 @@ var NavigationAPIAdapter = class {
301
344
  info: options?.info
302
345
  }).finished;
303
346
  }
304
- setupInterception(routes, onNavigate, checkBlockers) {
347
+ setupInterception(getRoutes, onNavigate, checkBlockers) {
305
348
  const handleNavigate = (event) => {
349
+ if (isBypassInterception(event.info)) {
350
+ onNavigate?.(event, {
351
+ matches: [],
352
+ intercepting: false,
353
+ formData: event.formData
354
+ });
355
+ return;
356
+ }
306
357
  this.#currentNavigationInfo = event.info;
307
358
  this.#cachedSnapshot = null;
308
359
  if (checkBlockers?.()) {
@@ -318,7 +369,7 @@ var NavigationAPIAdapter = class {
318
369
  return;
319
370
  }
320
371
  const url = new URL(event.destination.url);
321
- const matched = matchRoutes(routes, url.pathname);
372
+ const matched = matchRoutes(getRoutes(), url.pathname);
322
373
  const isFormSubmission = event.formData !== null;
323
374
  if (isFormSubmission && matched !== null) {
324
375
  if (!matched.some((m) => m.route.action)) {
@@ -340,6 +391,13 @@ var NavigationAPIAdapter = class {
340
391
  if (event.defaultPrevented) return;
341
392
  }
342
393
  if (!willIntercept) return;
394
+ if (event.navigationType === "reload") {
395
+ const entryId = navigation.currentEntry.id;
396
+ const oldCount = this.#reloadCounts.get(entryId) ?? 0;
397
+ if (oldCount >= 2) clearLoaderCacheForEntry(`${entryId}:r${oldCount - 1}`);
398
+ this.#reloadCounts.set(entryId, oldCount + 1);
399
+ this.#cachedSnapshot = null;
400
+ }
343
401
  if (idleController) {
344
402
  idleController.abort();
345
403
  idleController = null;
@@ -347,6 +405,7 @@ var NavigationAPIAdapter = class {
347
405
  event.intercept({ handler: async () => {
348
406
  const currentEntry = navigation.currentEntry;
349
407
  if (!currentEntry) throw new Error("Navigation currentEntry is null during navigation interception");
408
+ const effectiveKey = this.#effectiveKey(currentEntry.id);
350
409
  let actionResult = void 0;
351
410
  if (isFormSubmission) {
352
411
  const actionRoute = findActionRoute(matched);
@@ -360,8 +419,7 @@ var NavigationAPIAdapter = class {
360
419
  }
361
420
  clearLoaderCacheForEntry(currentEntry.id);
362
421
  }
363
- const request = createLoaderRequest(url);
364
- const results = executeLoaders(matched, currentEntry.id, request, event.signal, actionResult);
422
+ const results = executeLoaders(matched, effectiveKey, createLoaderRequest(url), event.signal, actionResult);
365
423
  await Promise.all(results.map((r) => r.data));
366
424
  } });
367
425
  };
@@ -387,7 +445,6 @@ var NavigationAPIAdapter = class {
387
445
  function findActionRoute(matched) {
388
446
  for (let i = matched.length - 1; i >= 0; i--) if (matched[i].route.action) return matched[i];
389
447
  }
390
-
391
448
  //#endregion
392
449
  //#region src/core/StaticAdapter.ts
393
450
  /**
@@ -403,6 +460,8 @@ var StaticAdapter = class {
403
460
  if (!this.#cachedSnapshot) this.#cachedSnapshot = {
404
461
  url: new URL(window.location.href),
405
462
  key: "__static__",
463
+ entryId: null,
464
+ entryKey: null,
406
465
  state: void 0,
407
466
  info: void 0
408
467
  };
@@ -417,14 +476,13 @@ var StaticAdapter = class {
417
476
  async navigateAsync(to, options) {
418
477
  this.navigate(to, options);
419
478
  }
420
- setupInterception(_routes, _onNavigate, _checkBlockers) {}
479
+ setupInterception(_getRoutes, _onNavigate, _checkBlockers) {}
421
480
  getIdleAbortSignal() {
422
481
  this.#idleController ??= new AbortController();
423
482
  return this.#idleController.signal;
424
483
  }
425
484
  updateCurrentEntryState(_state) {}
426
485
  };
427
-
428
486
  //#endregion
429
487
  //#region src/core/NullAdapter.ts
430
488
  /**
@@ -445,14 +503,13 @@ var NullAdapter = class {
445
503
  async navigateAsync(to, options) {
446
504
  this.navigate(to, options);
447
505
  }
448
- setupInterception(_routes, _onNavigate, _checkBlockers) {}
506
+ setupInterception(_getRoutes, _onNavigate, _checkBlockers) {}
449
507
  getIdleAbortSignal() {
450
508
  this.#idleController ??= new AbortController();
451
509
  return this.#idleController.signal;
452
510
  }
453
511
  updateCurrentEntryState(_state) {}
454
512
  };
455
-
456
513
  //#endregion
457
514
  //#region src/core/createAdapter.ts
458
515
  /**
@@ -473,7 +530,6 @@ function createAdapter(fallback) {
473
530
  if (fallback === "static") return new StaticAdapter();
474
531
  return new NullAdapter();
475
532
  }
476
-
477
533
  //#endregion
478
534
  //#region src/Router/ServerLocationSnapshot.ts
479
535
  /**
@@ -489,7 +545,6 @@ function isServerSnapshot(value) {
489
545
  return value instanceof ServerLocationSnapshot;
490
546
  }
491
547
  const noopSubscribe = () => () => {};
492
-
493
548
  //#endregion
494
549
  //#region src/context/RouteContext.ts
495
550
  const RouteContext = createContext(null);
@@ -505,7 +560,6 @@ function findRouteContextById(context, id) {
505
560
  }
506
561
  return null;
507
562
  }
508
-
509
563
  //#endregion
510
564
  //#region src/Router/useRouteStateCallbacks.ts
511
565
  function useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState) {
@@ -567,7 +621,6 @@ function useRouteStateCallbacks(index, internalState, url, navigateAsync, update
567
621
  resetStateSync
568
622
  };
569
623
  }
570
-
571
624
  //#endregion
572
625
  //#region src/Router/RouteRenderer.tsx
573
626
  /**
@@ -575,12 +628,11 @@ function useRouteStateCallbacks(index, internalState, url, navigateAsync, update
575
628
  */
576
629
  function RouteRenderer({ matchedRoutes, index }) {
577
630
  const parentRouteContext = useContext(RouteContext);
578
- const match = matchedRoutes[index];
579
- if (!match) return null;
580
- const { route, params, pathname, data } = match;
581
631
  const routerContext = useContext(RouterContext);
582
632
  if (!routerContext) throw new Error("RouteRenderer must be used within RouterContext");
583
633
  const { locationState, locationInfo, url, isPending, navigateAsync, updateCurrentEntryState } = routerContext;
634
+ const match = matchedRoutes[index];
635
+ const { route, params, pathname, data } = match ?? {};
584
636
  const internalState = locationState;
585
637
  const routeState = internalState?.__routeStates?.[index];
586
638
  const { setState, setStateSync, resetState, resetStateSync } = useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState);
@@ -588,11 +640,11 @@ function RouteRenderer({ matchedRoutes, index }) {
588
640
  matchedRoutes,
589
641
  index: index + 1
590
642
  }) : null, [matchedRoutes, index]);
591
- const routeId = route.id;
643
+ const routeId = route?.id;
592
644
  const routeContextValue = useMemo(() => ({
593
645
  id: routeId,
594
- params,
595
- matchedPath: pathname,
646
+ params: params ?? {},
647
+ matchedPath: pathname ?? "",
596
648
  state: routeState,
597
649
  data,
598
650
  outlet,
@@ -606,6 +658,7 @@ function RouteRenderer({ matchedRoutes, index }) {
606
658
  outlet,
607
659
  parentRouteContext
608
660
  ]);
661
+ if (!match) return null;
609
662
  const renderComponent = () => {
610
663
  const componentOrElement = route.component;
611
664
  if (componentOrElement == null) return outlet;
@@ -619,6 +672,7 @@ function RouteRenderer({ matchedRoutes, index }) {
619
672
  resetStateSync
620
673
  };
621
674
  const info = locationInfo;
675
+ if (data instanceof LoaderError) throw data.error;
622
676
  if (route.loader) return /* @__PURE__ */ jsx(Component, {
623
677
  data,
624
678
  params,
@@ -638,7 +692,6 @@ function RouteRenderer({ matchedRoutes, index }) {
638
692
  children: renderComponent()
639
693
  });
640
694
  }
641
-
642
695
  //#endregion
643
696
  //#region src/Router/index.tsx
644
697
  function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
@@ -662,17 +715,11 @@ function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
662
715
  else setLocationEntry(adapter.getSnapshot());
663
716
  });
664
717
  }, [adapter, startTransition]);
718
+ const getRoutes = useEffectEvent(() => routes);
719
+ const handleNavigate = useEffectEvent((...args) => onNavigate?.(...args));
665
720
  useEffect(() => {
666
- return adapter.setupInterception(routes, onNavigate, blockerRegistry.checkAll);
667
- }, [
668
- adapter,
669
- routes,
670
- onNavigate,
671
- blockerRegistry
672
- ]);
673
- const navigate = useCallback((to, options) => {
674
- adapter.navigate(to, options);
675
- }, [adapter]);
721
+ return adapter.setupInterception(getRoutes, handleNavigate, blockerRegistry.checkAll);
722
+ }, [adapter, blockerRegistry]);
676
723
  const navigateAsync = useCallback((to, options) => {
677
724
  return adapter.navigateAsync(to, options);
678
725
  }, [adapter]);
@@ -724,20 +771,24 @@ function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
724
771
  ]);
725
772
  const locationState = locationEntry?.state;
726
773
  const locationInfo = locationEntry?.info;
774
+ const entryId = locationEntry?.entryId ?? (isServerSnapshot(locationEntryInternal) ? locationEntryInternal.actualLocationEntry?.entryId : null) ?? null;
775
+ const entryKey = locationEntry?.entryKey ?? (isServerSnapshot(locationEntryInternal) ? locationEntryInternal.actualLocationEntry?.entryKey : null) ?? null;
727
776
  const routerContextValue = useMemo(() => ({
728
777
  locationState,
729
778
  locationInfo,
730
779
  url: urlObject,
780
+ entryId,
781
+ entryKey,
731
782
  isPending,
732
- navigate,
733
783
  navigateAsync,
734
784
  updateCurrentEntryState
735
785
  }), [
736
786
  locationState,
737
787
  locationInfo,
738
788
  urlObject,
789
+ entryId,
790
+ entryKey,
739
791
  isPending,
740
- navigate,
741
792
  navigateAsync,
742
793
  updateCurrentEntryState
743
794
  ]);
@@ -759,7 +810,6 @@ function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
759
810
  blockerRegistry
760
811
  ]);
761
812
  }
762
-
763
813
  //#endregion
764
814
  //#region src/Outlet.tsx
765
815
  /**
@@ -771,18 +821,6 @@ function Outlet() {
771
821
  if (!routeContext) return null;
772
822
  return routeContext.outlet;
773
823
  }
774
-
775
- //#endregion
776
- //#region src/hooks/useNavigate.ts
777
- /**
778
- * Returns a function for programmatic navigation.
779
- */
780
- function useNavigate() {
781
- const context = useContext(RouterContext);
782
- if (!context) throw new Error("useNavigate must be used within a Router");
783
- return context.navigate;
784
- }
785
-
786
824
  //#endregion
787
825
  //#region src/hooks/useLocation.ts
788
826
  /**
@@ -791,17 +829,22 @@ function useNavigate() {
791
829
  function useLocation() {
792
830
  const context = useContext(RouterContext);
793
831
  if (!context) throw new Error("useLocation must be used within a Router");
794
- const { url } = context;
832
+ const { url, entryId, entryKey } = context;
795
833
  if (url === null) throw new Error("useLocation: URL is not available during SSR.");
796
834
  return useMemo(() => {
797
835
  return {
798
836
  pathname: url.pathname,
799
837
  search: url.search,
800
- hash: url.hash
838
+ hash: url.hash,
839
+ entryId,
840
+ entryKey
801
841
  };
802
- }, [url]);
842
+ }, [
843
+ url,
844
+ entryId,
845
+ entryKey
846
+ ]);
803
847
  }
804
-
805
848
  //#endregion
806
849
  //#region src/hooks/useSearchParams.ts
807
850
  /**
@@ -812,7 +855,7 @@ function useSearchParams() {
812
855
  if (!context) throw new Error("useSearchParams must be used within a Router");
813
856
  if (context.url === null) throw new Error("useSearchParams: URL is not available during SSR.");
814
857
  const currentUrl = context.url;
815
- const { navigate } = context;
858
+ const { navigateAsync } = context;
816
859
  return [currentUrl.searchParams, useCallback((params) => {
817
860
  const url = new URL(currentUrl);
818
861
  let newParams;
@@ -822,10 +865,9 @@ function useSearchParams() {
822
865
  } else if (params instanceof URLSearchParams) newParams = params;
823
866
  else newParams = new URLSearchParams(params);
824
867
  url.search = newParams.toString();
825
- navigate(url.pathname + url.search + url.hash, { replace: true });
826
- }, [currentUrl, navigate])];
868
+ navigateAsync(url.pathname + url.search + url.hash, { replace: true });
869
+ }, [currentUrl, navigateAsync])];
827
870
  }
828
-
829
871
  //#endregion
830
872
  //#region src/hooks/useBlocker.ts
831
873
  /**
@@ -869,7 +911,6 @@ function useBlocker(options) {
869
911
  registry
870
912
  ]);
871
913
  }
872
-
873
914
  //#endregion
874
915
  //#region src/hooks/useRouteContext.ts
875
916
  /**
@@ -891,7 +932,6 @@ function useRouteContext(hookName, routeId) {
891
932
  if (!matchedContext) throw new Error(`${hookName}: Route ID "${routeId}" not found in current route hierarchy. Current route is "${context.id ?? "(no id)"}"`);
892
933
  return matchedContext;
893
934
  }
894
-
895
935
  //#endregion
896
936
  //#region src/hooks/useRouteParams.ts
897
937
  /**
@@ -918,7 +958,6 @@ function useRouteParams(route) {
918
958
  const routeId = route.id;
919
959
  return useRouteContext("useRouteParams", routeId).params;
920
960
  }
921
-
922
961
  //#endregion
923
962
  //#region src/hooks/useRouteState.ts
924
963
  /**
@@ -946,7 +985,6 @@ function useRouteState(route) {
946
985
  const routeId = route.id;
947
986
  return useRouteContext("useRouteState", routeId).state;
948
987
  }
949
-
950
988
  //#endregion
951
989
  //#region src/hooks/useRouteData.ts
952
990
  /**
@@ -977,7 +1015,6 @@ function useRouteData(route) {
977
1015
  const routeId = route.id;
978
1016
  return useRouteContext("useRouteData", routeId).data;
979
1017
  }
980
-
981
1018
  //#endregion
982
1019
  //#region src/hooks/useIsPending.ts
983
1020
  /**
@@ -988,7 +1025,7 @@ function useIsPending() {
988
1025
  if (!context) throw new Error("useIsPending must be used within a Router");
989
1026
  return context.isPending;
990
1027
  }
991
-
992
1028
  //#endregion
993
- export { Outlet, Router, route, routeState, useBlocker, useIsPending, useLocation, useNavigate, useRouteData, useRouteParams, useRouteState, useSearchParams };
1029
+ export { Outlet, Router, bindRoute, hardNavigate, hardReload, route, routeState, useBlocker, useIsPending, useLocation, useRouteData, useRouteParams, useRouteState, useSearchParams };
1030
+
994
1031
  //# sourceMappingURL=index.mjs.map