@funstack/router 0.0.8 → 0.0.10

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,28 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { n as routeState, t as route } from "./route-p_gr5yPI.mjs";
4
- import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, useSyncExternalStore, useTransition } from "react";
4
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
 
7
7
  //#region src/context/RouterContext.ts
8
8
  const RouterContext = createContext(null);
9
9
 
10
- //#endregion
11
- //#region src/context/RouteContext.ts
12
- const RouteContext = createContext(null);
13
- /**
14
- * Find a route context by ID in the ancestor chain.
15
- * Returns the matching context or null if not found.
16
- */
17
- function findRouteContextById(context, id) {
18
- let current = context;
19
- while (current !== null) {
20
- if (current.id === id) return current;
21
- current = current.parent;
22
- }
23
- return null;
24
- }
25
-
26
10
  //#endregion
27
11
  //#region src/context/BlockerContext.ts
28
12
  /**
@@ -61,6 +45,7 @@ function internalRoutes(routes) {
61
45
 
62
46
  //#endregion
63
47
  //#region src/core/matchRoutes.ts
48
+ const SKIPPED = Symbol("skipped");
64
49
  /**
65
50
  * Match a pathname against a route tree, returning the matched route stack.
66
51
  * Returns null if no match is found.
@@ -68,6 +53,7 @@ function internalRoutes(routes) {
68
53
  function matchRoutes(routes, pathname, options) {
69
54
  for (const route of routes) {
70
55
  const matched = matchRoute(route, pathname, options);
56
+ if (matched === SKIPPED) return null;
71
57
  if (matched) return matched;
72
58
  }
73
59
  return null;
@@ -78,7 +64,15 @@ function matchRoutes(routes, pathname, options) {
78
64
  function matchRoute(route, pathname, options) {
79
65
  const hasChildren = Boolean(route.children?.length);
80
66
  const skipLoaders = options?.skipLoaders ?? false;
81
- if ((pathname === null || skipLoaders) && route.loader) return null;
67
+ if ((pathname === null || skipLoaders) && route.loader) {
68
+ if (skipLoaders && pathname !== null) {
69
+ if (route.path === void 0) return SKIPPED;
70
+ const isExact = route.exact ?? !hasChildren;
71
+ const { matched } = matchPath(route.path, pathname, isExact);
72
+ if (matched) return SKIPPED;
73
+ }
74
+ return null;
75
+ }
82
76
  if (route.path === void 0) {
83
77
  const result = {
84
78
  route,
@@ -86,10 +80,19 @@ function matchRoute(route, pathname, options) {
86
80
  pathname: ""
87
81
  };
88
82
  if (hasChildren) {
83
+ let anySkipped = false;
89
84
  for (const child of route.children) {
90
85
  const childMatch = matchRoute(child, pathname, options);
86
+ if (childMatch === SKIPPED) {
87
+ anySkipped = true;
88
+ break;
89
+ }
91
90
  if (childMatch) return [result, ...childMatch];
92
91
  }
92
+ if (anySkipped) {
93
+ if (route.component) return [result];
94
+ return SKIPPED;
95
+ }
93
96
  if (route.component && route.requireChildren === false) return [result];
94
97
  if ((pathname === null || skipLoaders) && route.component) return [result];
95
98
  return null;
@@ -109,8 +112,13 @@ function matchRoute(route, pathname, options) {
109
112
  let remainingPathname = pathname.slice(consumedPathname.length);
110
113
  if (!remainingPathname.startsWith("/")) remainingPathname = "/" + remainingPathname;
111
114
  if (remainingPathname === "") remainingPathname = "/";
115
+ let anyChildSkipped = false;
112
116
  for (const child of route.children) {
113
117
  const childMatch = matchRoute(child, remainingPathname, options);
118
+ if (childMatch === SKIPPED) {
119
+ anyChildSkipped = true;
120
+ break;
121
+ }
114
122
  if (childMatch) return [result, ...childMatch.map((m) => ({
115
123
  ...m,
116
124
  params: {
@@ -119,6 +127,10 @@ function matchRoute(route, pathname, options) {
119
127
  }
120
128
  }))];
121
129
  }
130
+ if (anyChildSkipped) {
131
+ if (route.component) return [result];
132
+ return SKIPPED;
133
+ }
122
134
  if (route.component && route.requireChildren === false) return [result];
123
135
  if (skipLoaders && route.component) return [result];
124
136
  return null;
@@ -463,121 +475,55 @@ function createAdapter(fallback) {
463
475
  }
464
476
 
465
477
  //#endregion
466
- //#region src/Router.tsx
478
+ //#region src/Router/ServerLocationSnapshot.ts
467
479
  /**
468
- * Special value returned as server snapshot during SSR/hydration.
480
+ * Special class returned as server snapshot during SSR/hydration.
469
481
  */
470
- const serverSnapshotSymbol = Symbol();
471
- const noopSubscribe = () => () => {};
472
- const getServerSnapshot = () => serverSnapshotSymbol;
473
- function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssrPathname }) {
474
- const routes = internalRoutes(inputRoutes);
475
- const adapter = useMemo(() => createAdapter(fallback), [fallback]);
476
- const [blockerRegistry] = useState(() => createBlockerRegistry());
477
- const initialEntry = useSyncExternalStore(noopSubscribe, useCallback(() => adapter.getSnapshot(), [adapter]), getServerSnapshot);
478
- const [isPending, startTransition] = useTransition();
479
- const [locationEntryInternal, setLocationEntry] = useState(initialEntry);
480
- const locationEntry = locationEntryInternal === serverSnapshotSymbol ? null : locationEntryInternal;
481
- if (locationEntryInternal === serverSnapshotSymbol && initialEntry !== serverSnapshotSymbol) setLocationEntry(initialEntry);
482
- useEffect(() => {
483
- return adapter.subscribe((changeType) => {
484
- if (changeType === "navigation") startTransition(() => {
485
- setLocationEntry(adapter.getSnapshot());
486
- });
487
- else setLocationEntry(adapter.getSnapshot());
488
- });
489
- }, [adapter, startTransition]);
490
- useEffect(() => {
491
- return adapter.setupInterception(routes, onNavigate, blockerRegistry.checkAll);
492
- }, [
493
- adapter,
494
- routes,
495
- onNavigate,
496
- blockerRegistry
497
- ]);
498
- const navigate = useCallback((to, options) => {
499
- adapter.navigate(to, options);
500
- }, [adapter]);
501
- const navigateAsync = useCallback((to, options) => {
502
- return adapter.navigateAsync(to, options);
503
- }, [adapter]);
504
- const updateCurrentEntryState = useCallback((state) => {
505
- adapter.updateCurrentEntryState(state);
506
- }, [adapter]);
507
- return useMemo(() => {
508
- const matchedRoutesWithData = (() => {
509
- if (locationEntry === null) {
510
- const matched = matchRoutes(routes, ssrPathname ?? null, { skipLoaders: true });
511
- if (!matched) return null;
512
- return matched.map((m) => ({
513
- ...m,
514
- data: void 0
515
- }));
516
- }
517
- const { url, key } = locationEntry;
518
- const matched = matchRoutes(routes, url.pathname);
519
- if (!matched) return null;
520
- return executeLoaders(matched, key, createLoaderRequest(url), adapter.getIdleAbortSignal());
521
- })();
522
- const routerContextValue = {
523
- locationEntry,
524
- url: locationEntry?.url ?? null,
525
- isPending,
526
- navigate,
527
- navigateAsync,
528
- updateCurrentEntryState
529
- };
530
- const blockerContextValue = { registry: blockerRegistry };
531
- return /* @__PURE__ */ jsx(BlockerContext.Provider, {
532
- value: blockerContextValue,
533
- children: /* @__PURE__ */ jsx(RouterContext.Provider, {
534
- value: routerContextValue,
535
- children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
536
- matchedRoutes: matchedRoutesWithData,
537
- index: 0
538
- }) : null
539
- })
540
- });
541
- }, [
542
- navigate,
543
- navigateAsync,
544
- updateCurrentEntryState,
545
- isPending,
546
- locationEntry,
547
- routes,
548
- adapter,
549
- blockerRegistry,
550
- ssrPathname
551
- ]);
482
+ var ServerLocationSnapshot = class {
483
+ actualLocationEntry;
484
+ constructor(adapter) {
485
+ this.actualLocationEntry = adapter.getSnapshot();
486
+ }
487
+ };
488
+ function isServerSnapshot(value) {
489
+ return value instanceof ServerLocationSnapshot;
552
490
  }
491
+ const noopSubscribe = () => () => {};
492
+
493
+ //#endregion
494
+ //#region src/context/RouteContext.ts
495
+ const RouteContext = createContext(null);
553
496
  /**
554
- * Recursively render matched routes with proper context.
497
+ * Find a route context by ID in the ancestor chain.
498
+ * Returns the matching context or null if not found.
555
499
  */
556
- function RouteRenderer({ matchedRoutes, index }) {
557
- const parentRouteContext = useContext(RouteContext);
558
- const match = matchedRoutes[index];
559
- if (!match) return null;
560
- const { route, params, pathname, data } = match;
561
- const routerContext = useContext(RouterContext);
562
- if (!routerContext) throw new Error("RouteRenderer must be used within RouterContext");
563
- const { locationEntry, url, isPending, navigateAsync, updateCurrentEntryState } = routerContext;
564
- const routeState = (locationEntry?.state)?.__routeStates?.[index];
500
+ function findRouteContextById(context, id) {
501
+ let current = context;
502
+ while (current !== null) {
503
+ if (current.id === id) return current;
504
+ current = current.parent;
505
+ }
506
+ return null;
507
+ }
508
+
509
+ //#endregion
510
+ //#region src/Router/useRouteStateCallbacks.ts
511
+ function useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState) {
565
512
  const setStateSync = useCallback((stateOrUpdater) => {
566
- if (locationEntry === null) return;
567
- const currentStates = locationEntry.state?.__routeStates ?? [];
513
+ const currentStates = internalState?.__routeStates ?? [];
568
514
  const currentRouteState = currentStates[index];
569
515
  const newState = typeof stateOrUpdater === "function" ? stateOrUpdater(currentRouteState) : stateOrUpdater;
570
516
  const newStates = [...currentStates];
571
517
  newStates[index] = newState;
572
518
  updateCurrentEntryState({ __routeStates: newStates });
573
519
  }, [
574
- locationEntry?.state,
520
+ internalState,
575
521
  index,
576
522
  updateCurrentEntryState
577
523
  ]);
578
524
  const setState = useCallback(async (stateOrUpdater) => {
579
- if (locationEntry === null || url === null) return;
580
- const currentStates = locationEntry.state?.__routeStates ?? [];
525
+ if (url === null) return;
526
+ const currentStates = internalState?.__routeStates ?? [];
581
527
  const currentRouteState = currentStates[index];
582
528
  const newState = typeof stateOrUpdater === "function" ? stateOrUpdater(currentRouteState) : stateOrUpdater;
583
529
  const newStates = [...currentStates];
@@ -587,39 +533,61 @@ function RouteRenderer({ matchedRoutes, index }) {
587
533
  state: { __routeStates: newStates }
588
534
  });
589
535
  }, [
590
- locationEntry?.state,
536
+ internalState,
591
537
  index,
592
538
  url,
593
539
  navigateAsync
594
540
  ]);
595
541
  const resetStateSync = useCallback(() => {
596
- if (locationEntry === null) return;
597
- const newStates = [...locationEntry.state?.__routeStates ?? []];
542
+ const newStates = [...internalState?.__routeStates ?? []];
598
543
  newStates[index] = void 0;
599
544
  updateCurrentEntryState({ __routeStates: newStates });
600
545
  }, [
601
- locationEntry?.state,
546
+ internalState,
602
547
  index,
603
548
  updateCurrentEntryState
604
549
  ]);
605
- const resetState = useCallback(async () => {
606
- if (locationEntry === null || url === null) return;
607
- const newStates = [...locationEntry.state?.__routeStates ?? []];
608
- newStates[index] = void 0;
609
- await navigateAsync(url.href, {
610
- replace: true,
611
- state: { __routeStates: newStates }
612
- });
613
- }, [
614
- locationEntry?.state,
615
- index,
616
- url,
617
- navigateAsync
618
- ]);
619
- const outlet = index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
550
+ return {
551
+ setState,
552
+ setStateSync,
553
+ resetState: useCallback(async () => {
554
+ if (url === null) return;
555
+ const newStates = [...internalState?.__routeStates ?? []];
556
+ newStates[index] = void 0;
557
+ await navigateAsync(url.href, {
558
+ replace: true,
559
+ state: { __routeStates: newStates }
560
+ });
561
+ }, [
562
+ internalState,
563
+ index,
564
+ url,
565
+ navigateAsync
566
+ ]),
567
+ resetStateSync
568
+ };
569
+ }
570
+
571
+ //#endregion
572
+ //#region src/Router/RouteRenderer.tsx
573
+ /**
574
+ * Recursively render matched routes with proper context.
575
+ */
576
+ function RouteRenderer({ matchedRoutes, index }) {
577
+ const parentRouteContext = useContext(RouteContext);
578
+ const match = matchedRoutes[index];
579
+ if (!match) return null;
580
+ const { route, params, pathname, data } = match;
581
+ const routerContext = useContext(RouterContext);
582
+ if (!routerContext) throw new Error("RouteRenderer must be used within RouterContext");
583
+ const { locationState, locationInfo, url, isPending, navigateAsync, updateCurrentEntryState } = routerContext;
584
+ const internalState = locationState;
585
+ const routeState = internalState?.__routeStates?.[index];
586
+ const { setState, setStateSync, resetState, resetStateSync } = useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState);
587
+ const outlet = useMemo(() => index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
620
588
  matchedRoutes,
621
589
  index: index + 1
622
- }) : null;
590
+ }) : null, [matchedRoutes, index]);
623
591
  const routeId = route.id;
624
592
  const routeContextValue = useMemo(() => ({
625
593
  id: routeId,
@@ -650,7 +618,7 @@ function RouteRenderer({ matchedRoutes, index }) {
650
618
  resetState,
651
619
  resetStateSync
652
620
  };
653
- const info = locationEntry?.info;
621
+ const info = locationInfo;
654
622
  if (route.loader) return /* @__PURE__ */ jsx(Component, {
655
623
  data,
656
624
  params,
@@ -671,6 +639,127 @@ function RouteRenderer({ matchedRoutes, index }) {
671
639
  });
672
640
  }
673
641
 
642
+ //#endregion
643
+ //#region src/Router/index.tsx
644
+ function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
645
+ const routes = internalRoutes(inputRoutes);
646
+ const adapter = useMemo(() => createAdapter(fallback), [fallback]);
647
+ const [blockerRegistry] = useState(() => createBlockerRegistry());
648
+ const getSnapshot = useCallback(() => adapter.getSnapshot(), [adapter]);
649
+ const serverSnapshotCacheRef = useRef(null);
650
+ const initialEntry = useSyncExternalStore(noopSubscribe, getSnapshot, useCallback(() => {
651
+ return serverSnapshotCacheRef.current ??= new ServerLocationSnapshot(adapter);
652
+ }, [adapter]));
653
+ const [isPending, startTransition] = useTransition();
654
+ const [locationEntryInternal, setLocationEntry] = useState(initialEntry);
655
+ const locationEntry = isServerSnapshot(locationEntryInternal) ? null : locationEntryInternal;
656
+ if (isServerSnapshot(locationEntryInternal) && !isServerSnapshot(initialEntry)) setLocationEntry(initialEntry);
657
+ useEffect(() => {
658
+ return adapter.subscribe((changeType) => {
659
+ if (changeType === "navigation") startTransition(() => {
660
+ setLocationEntry(adapter.getSnapshot());
661
+ });
662
+ else setLocationEntry(adapter.getSnapshot());
663
+ });
664
+ }, [adapter, startTransition]);
665
+ 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]);
676
+ const navigateAsync = useCallback((to, options) => {
677
+ return adapter.navigateAsync(to, options);
678
+ }, [adapter]);
679
+ const updateCurrentEntryState = useCallback((state) => {
680
+ adapter.updateCurrentEntryState(state);
681
+ }, [adapter]);
682
+ const url = useMemo(() => {
683
+ if (locationEntry) return locationEntry.url.toString();
684
+ if (ssr) {
685
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
686
+ return new URL(ssr.path, origin).toString();
687
+ }
688
+ return null;
689
+ }, [locationEntry, ssr]);
690
+ /**
691
+ * URL object. Non-null when client-side or during SSR with ssr.path provided.
692
+ * Null during SSR without ssr.path.
693
+ */
694
+ const urlObject = useMemo(() => url ? new URL(url) : null, [url]);
695
+ /**
696
+ * Whether to run loaders.
697
+ * 1. Loaders are always run for rendering with URL available (client-side)
698
+ * 2. During SSR, loaders are only run if ssr.runLoaders is true and URL is available (ssr.path provided).
699
+ */
700
+ const runLoaders = locationEntry !== null || !!ssr?.runLoaders && urlObject !== null;
701
+ /**
702
+ * Key of location. This is used as the cache key for loader data saved in navigation entry.
703
+ */
704
+ const locationKey = locationEntry?.key ?? (isServerSnapshot(locationEntryInternal) ? locationEntryInternal.actualLocationEntry?.key : null) ?? "ssr";
705
+ const matchedRoutesWithData = useMemo(() => {
706
+ if (!runLoaders) {
707
+ const matched = matchRoutes(routes, urlObject?.pathname ?? null, { skipLoaders: true });
708
+ if (!matched) return null;
709
+ return matched.map((m) => ({
710
+ ...m,
711
+ data: void 0
712
+ }));
713
+ }
714
+ if (urlObject === null) throw new Error("Invariant failure: loaders cannot run without URL.");
715
+ const matched = matchRoutes(routes, urlObject.pathname);
716
+ if (!matched) return null;
717
+ return executeLoaders(matched, locationKey, createLoaderRequest(urlObject), adapter.getIdleAbortSignal());
718
+ }, [
719
+ routes,
720
+ adapter,
721
+ urlObject,
722
+ runLoaders,
723
+ locationKey
724
+ ]);
725
+ const locationState = locationEntry?.state;
726
+ const locationInfo = locationEntry?.info;
727
+ const routerContextValue = useMemo(() => ({
728
+ locationState,
729
+ locationInfo,
730
+ url: urlObject,
731
+ isPending,
732
+ navigate,
733
+ navigateAsync,
734
+ updateCurrentEntryState
735
+ }), [
736
+ locationState,
737
+ locationInfo,
738
+ urlObject,
739
+ isPending,
740
+ navigate,
741
+ navigateAsync,
742
+ updateCurrentEntryState
743
+ ]);
744
+ return useMemo(() => {
745
+ const blockerContextValue = { registry: blockerRegistry };
746
+ return /* @__PURE__ */ jsx(BlockerContext.Provider, {
747
+ value: blockerContextValue,
748
+ children: /* @__PURE__ */ jsx(RouterContext.Provider, {
749
+ value: routerContextValue,
750
+ children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
751
+ matchedRoutes: matchedRoutesWithData,
752
+ index: 0
753
+ }) : null
754
+ })
755
+ });
756
+ }, [
757
+ routerContextValue,
758
+ matchedRoutesWithData,
759
+ blockerRegistry
760
+ ]);
761
+ }
762
+
674
763
  //#endregion
675
764
  //#region src/Outlet.tsx
676
765
  /**
@@ -723,6 +812,7 @@ function useSearchParams() {
723
812
  if (!context) throw new Error("useSearchParams must be used within a Router");
724
813
  if (context.url === null) throw new Error("useSearchParams: URL is not available during SSR.");
725
814
  const currentUrl = context.url;
815
+ const { navigate } = context;
726
816
  return [currentUrl.searchParams, useCallback((params) => {
727
817
  const url = new URL(currentUrl);
728
818
  let newParams;
@@ -732,8 +822,8 @@ function useSearchParams() {
732
822
  } else if (params instanceof URLSearchParams) newParams = params;
733
823
  else newParams = new URLSearchParams(params);
734
824
  url.search = newParams.toString();
735
- context.navigate(url.pathname + url.search + url.hash, { replace: true });
736
- }, [currentUrl, context.navigate])];
825
+ navigate(url.pathname + url.search + url.hash, { replace: true });
826
+ }, [currentUrl, navigate])];
737
827
  }
738
828
 
739
829
  //#endregion