@funstack/router 0.0.9 → 1.0.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,28 +1,12 @@
1
1
  "use client";
2
2
 
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";
3
+ import { n as route, r as routeState, t as bindRoute } from "./bindRoute-C7JBYje-.mjs";
4
+ import { createContext, useCallback, useContext, useEffect, useEffectEvent, 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
  /**
@@ -184,6 +168,29 @@ function matchPath(pattern, pathname, exact) {
184
168
  };
185
169
  }
186
170
 
171
+ //#endregion
172
+ //#region src/bypassInterception.ts
173
+ const bypassInterceptionSymbol = Symbol("bypassInterception");
174
+ /**
175
+ * Check if the given info is a bypass interception marker.
176
+ */
177
+ function isBypassInterception(info) {
178
+ return info === bypassInterceptionSymbol;
179
+ }
180
+ /**
181
+ * Perform a full page reload, bypassing the router's interception.
182
+ */
183
+ function hardReload() {
184
+ navigation.reload({ info: bypassInterceptionSymbol });
185
+ }
186
+ /**
187
+ * Navigate to the given URL with a full page navigation,
188
+ * bypassing the router's interception.
189
+ */
190
+ function hardNavigate(url) {
191
+ navigation.navigate(url, { info: bypassInterceptionSymbol });
192
+ }
193
+
187
194
  //#endregion
188
195
  //#region src/core/loaderCache.ts
189
196
  /**
@@ -317,8 +324,16 @@ var NavigationAPIAdapter = class {
317
324
  info: options?.info
318
325
  }).finished;
319
326
  }
320
- setupInterception(routes, onNavigate, checkBlockers) {
327
+ setupInterception(getRoutes, onNavigate, checkBlockers) {
321
328
  const handleNavigate = (event) => {
329
+ if (isBypassInterception(event.info)) {
330
+ onNavigate?.(event, {
331
+ matches: [],
332
+ intercepting: false,
333
+ formData: event.formData
334
+ });
335
+ return;
336
+ }
322
337
  this.#currentNavigationInfo = event.info;
323
338
  this.#cachedSnapshot = null;
324
339
  if (checkBlockers?.()) {
@@ -334,7 +349,7 @@ var NavigationAPIAdapter = class {
334
349
  return;
335
350
  }
336
351
  const url = new URL(event.destination.url);
337
- const matched = matchRoutes(routes, url.pathname);
352
+ const matched = matchRoutes(getRoutes(), url.pathname);
338
353
  const isFormSubmission = event.formData !== null;
339
354
  if (isFormSubmission && matched !== null) {
340
355
  if (!matched.some((m) => m.route.action)) {
@@ -433,7 +448,7 @@ var StaticAdapter = class {
433
448
  async navigateAsync(to, options) {
434
449
  this.navigate(to, options);
435
450
  }
436
- setupInterception(_routes, _onNavigate, _checkBlockers) {}
451
+ setupInterception(_getRoutes, _onNavigate, _checkBlockers) {}
437
452
  getIdleAbortSignal() {
438
453
  this.#idleController ??= new AbortController();
439
454
  return this.#idleController.signal;
@@ -461,7 +476,7 @@ var NullAdapter = class {
461
476
  async navigateAsync(to, options) {
462
477
  this.navigate(to, options);
463
478
  }
464
- setupInterception(_routes, _onNavigate, _checkBlockers) {}
479
+ setupInterception(_getRoutes, _onNavigate, _checkBlockers) {}
465
480
  getIdleAbortSignal() {
466
481
  this.#idleController ??= new AbortController();
467
482
  return this.#idleController.signal;
@@ -491,121 +506,55 @@ function createAdapter(fallback) {
491
506
  }
492
507
 
493
508
  //#endregion
494
- //#region src/Router.tsx
509
+ //#region src/Router/ServerLocationSnapshot.ts
495
510
  /**
496
- * Special value returned as server snapshot during SSR/hydration.
511
+ * Special class returned as server snapshot during SSR/hydration.
497
512
  */
498
- const serverSnapshotSymbol = Symbol();
499
- const noopSubscribe = () => () => {};
500
- const getServerSnapshot = () => serverSnapshotSymbol;
501
- function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
502
- const routes = internalRoutes(inputRoutes);
503
- const adapter = useMemo(() => createAdapter(fallback), [fallback]);
504
- const [blockerRegistry] = useState(() => createBlockerRegistry());
505
- const initialEntry = useSyncExternalStore(noopSubscribe, useCallback(() => adapter.getSnapshot(), [adapter]), getServerSnapshot);
506
- const [isPending, startTransition] = useTransition();
507
- const [locationEntryInternal, setLocationEntry] = useState(initialEntry);
508
- const locationEntry = locationEntryInternal === serverSnapshotSymbol ? null : locationEntryInternal;
509
- if (locationEntryInternal === serverSnapshotSymbol && initialEntry !== serverSnapshotSymbol) setLocationEntry(initialEntry);
510
- useEffect(() => {
511
- return adapter.subscribe((changeType) => {
512
- if (changeType === "navigation") startTransition(() => {
513
- setLocationEntry(adapter.getSnapshot());
514
- });
515
- else setLocationEntry(adapter.getSnapshot());
516
- });
517
- }, [adapter, startTransition]);
518
- useEffect(() => {
519
- return adapter.setupInterception(routes, onNavigate, blockerRegistry.checkAll);
520
- }, [
521
- adapter,
522
- routes,
523
- onNavigate,
524
- blockerRegistry
525
- ]);
526
- const navigate = useCallback((to, options) => {
527
- adapter.navigate(to, options);
528
- }, [adapter]);
529
- const navigateAsync = useCallback((to, options) => {
530
- return adapter.navigateAsync(to, options);
531
- }, [adapter]);
532
- const updateCurrentEntryState = useCallback((state) => {
533
- adapter.updateCurrentEntryState(state);
534
- }, [adapter]);
535
- return useMemo(() => {
536
- const matchedRoutesWithData = (() => {
537
- if (locationEntry === null && !ssr?.runLoaders) {
538
- const matched = matchRoutes(routes, ssr?.path ?? null, { skipLoaders: true });
539
- if (!matched) return null;
540
- return matched.map((m) => ({
541
- ...m,
542
- data: void 0
543
- }));
544
- }
545
- const url = locationEntry ? locationEntry.url : new URL(ssr.path, "http://localhost");
546
- const matched = matchRoutes(routes, url.pathname);
547
- if (!matched) return null;
548
- return executeLoaders(matched, locationEntry?.key ?? "ssr", createLoaderRequest(url), locationEntry ? adapter.getIdleAbortSignal() : new AbortController().signal);
549
- })();
550
- const routerContextValue = {
551
- locationEntry,
552
- url: locationEntry?.url ?? (ssr ? new URL(ssr.path, "http://localhost") : null),
553
- isPending,
554
- navigate,
555
- navigateAsync,
556
- updateCurrentEntryState
557
- };
558
- const blockerContextValue = { registry: blockerRegistry };
559
- return /* @__PURE__ */ jsx(BlockerContext.Provider, {
560
- value: blockerContextValue,
561
- children: /* @__PURE__ */ jsx(RouterContext.Provider, {
562
- value: routerContextValue,
563
- children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
564
- matchedRoutes: matchedRoutesWithData,
565
- index: 0
566
- }) : null
567
- })
568
- });
569
- }, [
570
- navigate,
571
- navigateAsync,
572
- updateCurrentEntryState,
573
- isPending,
574
- locationEntry,
575
- routes,
576
- adapter,
577
- blockerRegistry,
578
- ssr
579
- ]);
513
+ var ServerLocationSnapshot = class {
514
+ actualLocationEntry;
515
+ constructor(adapter) {
516
+ this.actualLocationEntry = adapter.getSnapshot();
517
+ }
518
+ };
519
+ function isServerSnapshot(value) {
520
+ return value instanceof ServerLocationSnapshot;
580
521
  }
522
+ const noopSubscribe = () => () => {};
523
+
524
+ //#endregion
525
+ //#region src/context/RouteContext.ts
526
+ const RouteContext = createContext(null);
581
527
  /**
582
- * Recursively render matched routes with proper context.
528
+ * Find a route context by ID in the ancestor chain.
529
+ * Returns the matching context or null if not found.
583
530
  */
584
- function RouteRenderer({ matchedRoutes, index }) {
585
- const parentRouteContext = useContext(RouteContext);
586
- const match = matchedRoutes[index];
587
- if (!match) return null;
588
- const { route, params, pathname, data } = match;
589
- const routerContext = useContext(RouterContext);
590
- if (!routerContext) throw new Error("RouteRenderer must be used within RouterContext");
591
- const { locationEntry, url, isPending, navigateAsync, updateCurrentEntryState } = routerContext;
592
- const routeState = (locationEntry?.state)?.__routeStates?.[index];
531
+ function findRouteContextById(context, id) {
532
+ let current = context;
533
+ while (current !== null) {
534
+ if (current.id === id) return current;
535
+ current = current.parent;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ //#endregion
541
+ //#region src/Router/useRouteStateCallbacks.ts
542
+ function useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState) {
593
543
  const setStateSync = useCallback((stateOrUpdater) => {
594
- if (locationEntry === null) return;
595
- const currentStates = locationEntry.state?.__routeStates ?? [];
544
+ const currentStates = internalState?.__routeStates ?? [];
596
545
  const currentRouteState = currentStates[index];
597
546
  const newState = typeof stateOrUpdater === "function" ? stateOrUpdater(currentRouteState) : stateOrUpdater;
598
547
  const newStates = [...currentStates];
599
548
  newStates[index] = newState;
600
549
  updateCurrentEntryState({ __routeStates: newStates });
601
550
  }, [
602
- locationEntry?.state,
551
+ internalState,
603
552
  index,
604
553
  updateCurrentEntryState
605
554
  ]);
606
555
  const setState = useCallback(async (stateOrUpdater) => {
607
- if (locationEntry === null || url === null) return;
608
- const currentStates = locationEntry.state?.__routeStates ?? [];
556
+ if (url === null) return;
557
+ const currentStates = internalState?.__routeStates ?? [];
609
558
  const currentRouteState = currentStates[index];
610
559
  const newState = typeof stateOrUpdater === "function" ? stateOrUpdater(currentRouteState) : stateOrUpdater;
611
560
  const newStates = [...currentStates];
@@ -615,44 +564,65 @@ function RouteRenderer({ matchedRoutes, index }) {
615
564
  state: { __routeStates: newStates }
616
565
  });
617
566
  }, [
618
- locationEntry?.state,
567
+ internalState,
619
568
  index,
620
569
  url,
621
570
  navigateAsync
622
571
  ]);
623
572
  const resetStateSync = useCallback(() => {
624
- if (locationEntry === null) return;
625
- const newStates = [...locationEntry.state?.__routeStates ?? []];
573
+ const newStates = [...internalState?.__routeStates ?? []];
626
574
  newStates[index] = void 0;
627
575
  updateCurrentEntryState({ __routeStates: newStates });
628
576
  }, [
629
- locationEntry?.state,
577
+ internalState,
630
578
  index,
631
579
  updateCurrentEntryState
632
580
  ]);
633
- const resetState = useCallback(async () => {
634
- if (locationEntry === null || url === null) return;
635
- const newStates = [...locationEntry.state?.__routeStates ?? []];
636
- newStates[index] = void 0;
637
- await navigateAsync(url.href, {
638
- replace: true,
639
- state: { __routeStates: newStates }
640
- });
641
- }, [
642
- locationEntry?.state,
643
- index,
644
- url,
645
- navigateAsync
646
- ]);
647
- const outlet = index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
581
+ return {
582
+ setState,
583
+ setStateSync,
584
+ resetState: useCallback(async () => {
585
+ if (url === null) return;
586
+ const newStates = [...internalState?.__routeStates ?? []];
587
+ newStates[index] = void 0;
588
+ await navigateAsync(url.href, {
589
+ replace: true,
590
+ state: { __routeStates: newStates }
591
+ });
592
+ }, [
593
+ internalState,
594
+ index,
595
+ url,
596
+ navigateAsync
597
+ ]),
598
+ resetStateSync
599
+ };
600
+ }
601
+
602
+ //#endregion
603
+ //#region src/Router/RouteRenderer.tsx
604
+ /**
605
+ * Recursively render matched routes with proper context.
606
+ */
607
+ function RouteRenderer({ matchedRoutes, index }) {
608
+ const parentRouteContext = useContext(RouteContext);
609
+ const routerContext = useContext(RouterContext);
610
+ if (!routerContext) throw new Error("RouteRenderer must be used within RouterContext");
611
+ const { locationState, locationInfo, url, isPending, navigateAsync, updateCurrentEntryState } = routerContext;
612
+ const match = matchedRoutes[index];
613
+ const { route, params, pathname, data } = match ?? {};
614
+ const internalState = locationState;
615
+ const routeState = internalState?.__routeStates?.[index];
616
+ const { setState, setStateSync, resetState, resetStateSync } = useRouteStateCallbacks(index, internalState, url, navigateAsync, updateCurrentEntryState);
617
+ const outlet = useMemo(() => index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
648
618
  matchedRoutes,
649
619
  index: index + 1
650
- }) : null;
651
- const routeId = route.id;
620
+ }) : null, [matchedRoutes, index]);
621
+ const routeId = route?.id;
652
622
  const routeContextValue = useMemo(() => ({
653
623
  id: routeId,
654
- params,
655
- matchedPath: pathname,
624
+ params: params ?? {},
625
+ matchedPath: pathname ?? "",
656
626
  state: routeState,
657
627
  data,
658
628
  outlet,
@@ -666,6 +636,7 @@ function RouteRenderer({ matchedRoutes, index }) {
666
636
  outlet,
667
637
  parentRouteContext
668
638
  ]);
639
+ if (!match) return null;
669
640
  const renderComponent = () => {
670
641
  const componentOrElement = route.component;
671
642
  if (componentOrElement == null) return outlet;
@@ -678,7 +649,7 @@ function RouteRenderer({ matchedRoutes, index }) {
678
649
  resetState,
679
650
  resetStateSync
680
651
  };
681
- const info = locationEntry?.info;
652
+ const info = locationInfo;
682
653
  if (route.loader) return /* @__PURE__ */ jsx(Component, {
683
654
  data,
684
655
  params,
@@ -699,6 +670,119 @@ function RouteRenderer({ matchedRoutes, index }) {
699
670
  });
700
671
  }
701
672
 
673
+ //#endregion
674
+ //#region src/Router/index.tsx
675
+ function Router({ routes: inputRoutes, onNavigate, fallback = "none", ssr }) {
676
+ const routes = internalRoutes(inputRoutes);
677
+ const adapter = useMemo(() => createAdapter(fallback), [fallback]);
678
+ const [blockerRegistry] = useState(() => createBlockerRegistry());
679
+ const getSnapshot = useCallback(() => adapter.getSnapshot(), [adapter]);
680
+ const serverSnapshotCacheRef = useRef(null);
681
+ const initialEntry = useSyncExternalStore(noopSubscribe, getSnapshot, useCallback(() => {
682
+ return serverSnapshotCacheRef.current ??= new ServerLocationSnapshot(adapter);
683
+ }, [adapter]));
684
+ const [isPending, startTransition] = useTransition();
685
+ const [locationEntryInternal, setLocationEntry] = useState(initialEntry);
686
+ const locationEntry = isServerSnapshot(locationEntryInternal) ? null : locationEntryInternal;
687
+ if (isServerSnapshot(locationEntryInternal) && !isServerSnapshot(initialEntry)) setLocationEntry(initialEntry);
688
+ useEffect(() => {
689
+ return adapter.subscribe((changeType) => {
690
+ if (changeType === "navigation") startTransition(() => {
691
+ setLocationEntry(adapter.getSnapshot());
692
+ });
693
+ else setLocationEntry(adapter.getSnapshot());
694
+ });
695
+ }, [adapter, startTransition]);
696
+ const getRoutes = useEffectEvent(() => routes);
697
+ const handleNavigate = useEffectEvent((...args) => onNavigate?.(...args));
698
+ useEffect(() => {
699
+ return adapter.setupInterception(getRoutes, handleNavigate, blockerRegistry.checkAll);
700
+ }, [adapter, blockerRegistry]);
701
+ const navigateAsync = useCallback((to, options) => {
702
+ return adapter.navigateAsync(to, options);
703
+ }, [adapter]);
704
+ const updateCurrentEntryState = useCallback((state) => {
705
+ adapter.updateCurrentEntryState(state);
706
+ }, [adapter]);
707
+ const url = useMemo(() => {
708
+ if (locationEntry) return locationEntry.url.toString();
709
+ if (ssr) {
710
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
711
+ return new URL(ssr.path, origin).toString();
712
+ }
713
+ return null;
714
+ }, [locationEntry, ssr]);
715
+ /**
716
+ * URL object. Non-null when client-side or during SSR with ssr.path provided.
717
+ * Null during SSR without ssr.path.
718
+ */
719
+ const urlObject = useMemo(() => url ? new URL(url) : null, [url]);
720
+ /**
721
+ * Whether to run loaders.
722
+ * 1. Loaders are always run for rendering with URL available (client-side)
723
+ * 2. During SSR, loaders are only run if ssr.runLoaders is true and URL is available (ssr.path provided).
724
+ */
725
+ const runLoaders = locationEntry !== null || !!ssr?.runLoaders && urlObject !== null;
726
+ /**
727
+ * Key of location. This is used as the cache key for loader data saved in navigation entry.
728
+ */
729
+ const locationKey = locationEntry?.key ?? (isServerSnapshot(locationEntryInternal) ? locationEntryInternal.actualLocationEntry?.key : null) ?? "ssr";
730
+ const matchedRoutesWithData = useMemo(() => {
731
+ if (!runLoaders) {
732
+ const matched = matchRoutes(routes, urlObject?.pathname ?? null, { skipLoaders: true });
733
+ if (!matched) return null;
734
+ return matched.map((m) => ({
735
+ ...m,
736
+ data: void 0
737
+ }));
738
+ }
739
+ if (urlObject === null) throw new Error("Invariant failure: loaders cannot run without URL.");
740
+ const matched = matchRoutes(routes, urlObject.pathname);
741
+ if (!matched) return null;
742
+ return executeLoaders(matched, locationKey, createLoaderRequest(urlObject), adapter.getIdleAbortSignal());
743
+ }, [
744
+ routes,
745
+ adapter,
746
+ urlObject,
747
+ runLoaders,
748
+ locationKey
749
+ ]);
750
+ const locationState = locationEntry?.state;
751
+ const locationInfo = locationEntry?.info;
752
+ const routerContextValue = useMemo(() => ({
753
+ locationState,
754
+ locationInfo,
755
+ url: urlObject,
756
+ isPending,
757
+ navigateAsync,
758
+ updateCurrentEntryState
759
+ }), [
760
+ locationState,
761
+ locationInfo,
762
+ urlObject,
763
+ isPending,
764
+ navigateAsync,
765
+ updateCurrentEntryState
766
+ ]);
767
+ return useMemo(() => {
768
+ const blockerContextValue = { registry: blockerRegistry };
769
+ return /* @__PURE__ */ jsx(BlockerContext.Provider, {
770
+ value: blockerContextValue,
771
+ children: /* @__PURE__ */ jsx(RouterContext.Provider, {
772
+ value: routerContextValue,
773
+ children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
774
+ matchedRoutes: matchedRoutesWithData,
775
+ index: 0
776
+ }) : null
777
+ })
778
+ });
779
+ }, [
780
+ routerContextValue,
781
+ matchedRoutesWithData,
782
+ blockerRegistry
783
+ ]);
784
+ }
785
+
702
786
  //#endregion
703
787
  //#region src/Outlet.tsx
704
788
  /**
@@ -711,17 +795,6 @@ function Outlet() {
711
795
  return routeContext.outlet;
712
796
  }
713
797
 
714
- //#endregion
715
- //#region src/hooks/useNavigate.ts
716
- /**
717
- * Returns a function for programmatic navigation.
718
- */
719
- function useNavigate() {
720
- const context = useContext(RouterContext);
721
- if (!context) throw new Error("useNavigate must be used within a Router");
722
- return context.navigate;
723
- }
724
-
725
798
  //#endregion
726
799
  //#region src/hooks/useLocation.ts
727
800
  /**
@@ -751,6 +824,7 @@ function useSearchParams() {
751
824
  if (!context) throw new Error("useSearchParams must be used within a Router");
752
825
  if (context.url === null) throw new Error("useSearchParams: URL is not available during SSR.");
753
826
  const currentUrl = context.url;
827
+ const { navigateAsync } = context;
754
828
  return [currentUrl.searchParams, useCallback((params) => {
755
829
  const url = new URL(currentUrl);
756
830
  let newParams;
@@ -760,8 +834,8 @@ function useSearchParams() {
760
834
  } else if (params instanceof URLSearchParams) newParams = params;
761
835
  else newParams = new URLSearchParams(params);
762
836
  url.search = newParams.toString();
763
- context.navigate(url.pathname + url.search + url.hash, { replace: true });
764
- }, [currentUrl, context.navigate])];
837
+ navigateAsync(url.pathname + url.search + url.hash, { replace: true });
838
+ }, [currentUrl, navigateAsync])];
765
839
  }
766
840
 
767
841
  //#endregion
@@ -928,5 +1002,5 @@ function useIsPending() {
928
1002
  }
929
1003
 
930
1004
  //#endregion
931
- export { Outlet, Router, route, routeState, useBlocker, useIsPending, useLocation, useNavigate, useRouteData, useRouteParams, useRouteState, useSearchParams };
1005
+ export { Outlet, Router, bindRoute, hardNavigate, hardReload, route, routeState, useBlocker, useIsPending, useLocation, useRouteData, useRouteParams, useRouteState, useSearchParams };
932
1006
  //# sourceMappingURL=index.mjs.map