@rangojs/router 0.0.0-experimental.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.
Files changed (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import {
4
+ useContext,
5
+ useState,
6
+ useEffect,
7
+ useRef,
8
+ useOptimistic,
9
+ startTransition,
10
+ } from "react";
11
+ import type { Handle } from "../../handle.js";
12
+ import { getCollectFn } from "../../handle.js";
13
+ import type { HandleData } from "../types.js";
14
+ import { NavigationStoreContext } from "./context.js";
15
+
16
+ /**
17
+ * SSR module-level state.
18
+ * Populated by initHandleDataSync before React renders.
19
+ * Used by useState initializer during SSR.
20
+ */
21
+ let ssrHandleData: HandleData = {};
22
+ let ssrSegmentOrder: string[] = [];
23
+
24
+ /**
25
+ * Filter segment IDs to only include routes and layouts.
26
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
27
+ */
28
+ function filterSegmentOrder(matched: string[]): string[] {
29
+ return matched.filter((id) => {
30
+ if (id.includes(".@")) return false;
31
+ if (/D\d+\./.test(id)) return false;
32
+ return true;
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Resolve the collect function for a handle.
38
+ * Handle objects are plain { __brand, $$id } - collect is stored in the registry
39
+ * (populated when createHandle runs on the client).
40
+ */
41
+ function resolveCollect<T, A>(handle: Handle<T, A>): (segments: T[][]) => A {
42
+ // Look up collect from the registry (populated when the handle module is imported).
43
+ const registered = getCollectFn(handle.$$id);
44
+ if (registered) {
45
+ return registered as (segments: T[][]) => A;
46
+ }
47
+
48
+ // Fall back to default flat collect with a dev warning.
49
+ if (process.env.NODE_ENV !== "production") {
50
+ console.warn(
51
+ `[rsc-router] Handle "${handle.$$id}" was passed as a prop but its collect ` +
52
+ `function could not be resolved. Falling back to flat array. ` +
53
+ `Import the handle module in a client component to register its collect function.`
54
+ );
55
+ }
56
+ return ((segments: unknown[][]) => segments.flat()) as unknown as (segments: T[][]) => A;
57
+ }
58
+
59
+ /**
60
+ * Collect handle data from segments and transform to final value.
61
+ */
62
+ function collectHandle<T, A>(
63
+ handle: Handle<T, A>,
64
+ data: HandleData,
65
+ segmentOrder: string[]
66
+ ): A {
67
+ const collect = resolveCollect(handle);
68
+ const segmentData = data[handle.$$id];
69
+
70
+ if (!segmentData) {
71
+ return collect([]);
72
+ }
73
+
74
+ // Build array of segment arrays in parent -> child order
75
+ const segmentArrays: T[][] = [];
76
+ for (const segmentId of segmentOrder) {
77
+ const entries = segmentData[segmentId];
78
+ if (entries && entries.length > 0) {
79
+ segmentArrays.push(entries as T[]);
80
+ }
81
+ }
82
+
83
+ // Call collect once with all segment data
84
+ return collect(segmentArrays);
85
+ }
86
+
87
+ /**
88
+ * Shallow equality check for selector results.
89
+ */
90
+ function shallowEqual<T>(a: T, b: T): boolean {
91
+ if (Object.is(a, b)) return true;
92
+ if (
93
+ typeof a !== "object" ||
94
+ a === null ||
95
+ typeof b !== "object" ||
96
+ b === null
97
+ ) {
98
+ return false;
99
+ }
100
+ const keysA = Object.keys(a);
101
+ const keysB = Object.keys(b);
102
+ if (keysA.length !== keysB.length) return false;
103
+ for (const key of keysA) {
104
+ if (
105
+ !Object.hasOwn(b, key) ||
106
+ !Object.is((a as any)[key], (b as any)[key])
107
+ ) {
108
+ return false;
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Initialize handle data synchronously for SSR.
116
+ * Called before rendering to populate state for useState initializer.
117
+ *
118
+ * @param data - Handle data from RSC payload
119
+ * @param matched - Segment order for reduction
120
+ */
121
+ export function initHandleDataSync(data: HandleData, matched?: string[]): void {
122
+ ssrHandleData = data;
123
+ ssrSegmentOrder = filterSegmentOrder(matched ?? []);
124
+ }
125
+
126
+ /**
127
+ * Hook to access collected handle data.
128
+ *
129
+ * Returns the collected value from all route segments that pushed to this handle.
130
+ * Re-renders when handle data changes (navigation, actions).
131
+ *
132
+ * @param handle - The handle to read
133
+ * @param selector - Optional selector for performance (only re-render when selected value changes)
134
+ *
135
+ * @example
136
+ * ```tsx
137
+ * // Get all breadcrumbs
138
+ * const breadcrumbs = useHandle(Breadcrumbs);
139
+ *
140
+ * // With selector - only re-render when last crumb changes
141
+ * const lastCrumb = useHandle(Breadcrumbs, (data) => data.at(-1));
142
+ * ```
143
+ */
144
+ export function useHandle<T, A>(handle: Handle<T, A>): A;
145
+ export function useHandle<T, A, S>(
146
+ handle: Handle<T, A>,
147
+ selector: (data: A) => S
148
+ ): S;
149
+ export function useHandle<T, A, S>(
150
+ handle: Handle<T, A>,
151
+ selector?: (data: A) => S
152
+ ): A | S {
153
+ const ctx = useContext(NavigationStoreContext);
154
+
155
+ // Initial state from SSR module state or event controller
156
+ const [value, setValue] = useState<A | S>(() => {
157
+ // During SSR, use module-level state
158
+ if (typeof document === "undefined" || !ctx) {
159
+ const collected = collectHandle(handle, ssrHandleData, ssrSegmentOrder);
160
+ return selector ? selector(collected) : collected;
161
+ }
162
+
163
+ // On client, use event controller state
164
+ const state = ctx.eventController.getHandleState();
165
+ const collected = collectHandle(handle, state.data, state.segmentOrder);
166
+ return selector ? selector(collected) : collected;
167
+ });
168
+ const [optimisticValue, setOptimisticValue] = useOptimistic(value);
169
+
170
+ // Track previous value for shallow comparison
171
+ const prevValueRef = useRef(value);
172
+ prevValueRef.current = value;
173
+
174
+ // Memoize selector ref
175
+ const selectorRef = useRef(selector);
176
+ selectorRef.current = selector;
177
+
178
+ // Subscribe to handle data changes (client only)
179
+ useEffect(() => {
180
+ if (!ctx) return;
181
+
182
+ return ctx.eventController.subscribeToHandles(() => {
183
+ const state = ctx.eventController.getHandleState();
184
+ const isAction =
185
+ ctx.eventController.getState().inflightActions.length > 0;
186
+ const collected = collectHandle(handle, state.data, state.segmentOrder);
187
+ const nextValue = selectorRef.current
188
+ ? selectorRef.current(collected)
189
+ : collected;
190
+
191
+ if (!shallowEqual(nextValue, prevValueRef.current)) {
192
+ prevValueRef.current = nextValue;
193
+ startTransition(() => {
194
+ // Skip optimistic update during actions to prevent Suspense fallback
195
+ if (!isAction) setOptimisticValue(nextValue);
196
+ setValue(nextValue);
197
+ });
198
+ }
199
+ });
200
+ }, [handle]);
201
+
202
+ return optimisticValue;
203
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { href, type ValidPaths } from "../../href-client.js";
4
+ import { useMount } from "./use-mount.js";
5
+
6
+ /**
7
+ * Client-side hook for mount-aware URL resolution.
8
+ *
9
+ * Returns an href function that automatically prepends the current
10
+ * include() mount prefix. Inside an include("/shop", ...) scope,
11
+ * the returned function resolves local paths relative to /shop.
12
+ *
13
+ * For absolute paths (outside the current mount), use the bare
14
+ * href() function directly instead.
15
+ *
16
+ * @returns A function that prepends the mount prefix to paths
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * "use client";
21
+ * import { useHref, href } from "@rangojs/router/client";
22
+ *
23
+ * // Inside include("/shop", shopPatterns)
24
+ * function ShopNav() {
25
+ * const href = useHref();
26
+ *
27
+ * return (
28
+ * <>
29
+ * {// Local paths - auto-prefixed with /shop}
30
+ * <Link to={href("/cart")}>Cart</Link>
31
+ * <Link to={href("/product/widget")}>Widget</Link>
32
+ * </>
33
+ * );
34
+ * }
35
+ * ```
36
+ */
37
+ export function useHref(): (path: `/${string}`) => string {
38
+ const mount = useMount();
39
+ return (path: `/${string}`) => href(path as ValidPaths, mount);
40
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useEffect,
8
+ useRef,
9
+ useOptimistic,
10
+ startTransition,
11
+ type Context,
12
+ } from "react";
13
+ import { NavigationStoreContext } from "./context.js";
14
+
15
+ /**
16
+ * Context for Link component to provide its destination URL
17
+ * Used by useLinkStatus to determine if this specific link is pending
18
+ */
19
+ export const LinkContext: Context<string | null> = createContext<string | null>(null);
20
+
21
+ /**
22
+ * Link status returned by useLinkStatus hook
23
+ */
24
+ export interface LinkStatus {
25
+ /** Whether navigation to this link's destination is in progress */
26
+ pending: boolean;
27
+ }
28
+
29
+ /**
30
+ * Normalize URL for comparison
31
+ * Handles relative URLs and ensures consistent format
32
+ */
33
+ function normalizeUrl(url: string, origin: string): string {
34
+ try {
35
+ const parsed = new URL(url, origin);
36
+ // Return pathname + search + hash for comparison
37
+ return parsed.pathname + parsed.search + parsed.hash;
38
+ } catch {
39
+ return url;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if this link's destination matches the pending navigation URL
45
+ */
46
+ function isPendingFor(
47
+ linkTo: string | null,
48
+ pendingUrl: string | null,
49
+ origin: string
50
+ ): boolean {
51
+ if (linkTo === null || pendingUrl === null) {
52
+ return false;
53
+ }
54
+ return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
55
+ }
56
+
57
+ /**
58
+ * Hook to track the pending state of a Link component
59
+ *
60
+ * Must be used inside a Link component. Returns `{ pending: true }`
61
+ * when navigation to this link's destination is in progress.
62
+ *
63
+ * Useful for showing inline loading indicators on individual links.
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * function LoadingIndicator() {
68
+ * const { pending } = useLinkStatus();
69
+ * return pending ? <Spinner /> : null;
70
+ * }
71
+ *
72
+ * // In your component:
73
+ * <Link to="/dashboard">
74
+ * Dashboard
75
+ * <LoadingIndicator />
76
+ * </Link>
77
+ * ```
78
+ */
79
+ export function useLinkStatus(): LinkStatus {
80
+ const linkTo = useContext(LinkContext);
81
+ const ctx = useContext(NavigationStoreContext);
82
+
83
+ // Get origin for URL normalization (stable across renders)
84
+ const origin = typeof window !== "undefined"
85
+ ? window.location.origin
86
+ : "http://localhost";
87
+
88
+ // Base state for useOptimistic
89
+ const [basePending, setBasePending] = useState<boolean>(() => {
90
+ if (!ctx || linkTo === null) {
91
+ return false;
92
+ }
93
+ const state = ctx.eventController.getState();
94
+ return isPendingFor(linkTo, state.pendingUrl, origin);
95
+ });
96
+
97
+ const prevPending = useRef(basePending);
98
+
99
+ // useOptimistic allows immediate updates during transitions
100
+ const [pending, setOptimisticPending] = useOptimistic(basePending);
101
+
102
+ useEffect(() => {
103
+ if (!ctx || linkTo === null) {
104
+ return;
105
+ }
106
+
107
+ // Subscribe to navigation state changes
108
+ return ctx.eventController.subscribe(() => {
109
+ const state = ctx.eventController.getState();
110
+ const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
111
+
112
+ if (isPending !== prevPending.current) {
113
+ prevPending.current = isPending;
114
+
115
+ // Use optimistic update for immediate feedback during navigation
116
+ if (state.state !== "idle") {
117
+ startTransition(() => {
118
+ setOptimisticPending(isPending);
119
+ });
120
+ }
121
+
122
+ // Always update base state
123
+ setBasePending(isPending);
124
+ }
125
+ });
126
+ }, [linkTo, origin]);
127
+
128
+ // If not inside a Link, return not pending
129
+ if (linkTo === null) {
130
+ return { pending: false };
131
+ }
132
+
133
+ return { pending };
134
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { MountContext } from "./mount-context.js";
5
+
6
+ /**
7
+ * Returns the current include() mount path.
8
+ *
9
+ * Inside `include("/articles", blogPatterns)`, returns "/articles".
10
+ * For nested includes, returns the nearest mount path.
11
+ * At root level (no include), returns "/".
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * "use client";
16
+ * import { useMount, href } from "@rangojs/router/client";
17
+ *
18
+ * function BlogNav({ slug }: { slug: string }) {
19
+ * const mount = useMount(); // "/articles"
20
+ * return (
21
+ * <>
22
+ * <Link to={href("/", mount)}>Blog Home</Link>
23
+ * <Link to={href(`/${slug}`, mount)}>Post</Link>
24
+ * </>
25
+ * );
26
+ * }
27
+ * ```
28
+ */
29
+ export function useMount(): string {
30
+ return useContext(MountContext);
31
+ }
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import {
4
+ useContext,
5
+ useState,
6
+ useEffect,
7
+ useOptimistic,
8
+ startTransition,
9
+ useRef,
10
+ } from "react";
11
+ import { NavigationStoreContext } from "./context.js";
12
+ import type { PublicNavigationState, NavigateOptions } from "../types.js";
13
+ import type { DerivedNavigationState } from "../event-controller.js";
14
+
15
+ /**
16
+ * Shallow equality check for selector results
17
+ */
18
+ function shallowEqual<T>(a: T, b: T): boolean {
19
+ if (Object.is(a, b)) return true;
20
+ if (
21
+ typeof a !== "object" ||
22
+ a === null ||
23
+ typeof b !== "object" ||
24
+ b === null
25
+ ) {
26
+ return false;
27
+ }
28
+ const keysA = Object.keys(a);
29
+ const keysB = Object.keys(b);
30
+ if (keysA.length !== keysB.length) return false;
31
+ for (const key of keysA) {
32
+ if (
33
+ !Object.hasOwn(b, key) ||
34
+ !Object.is((a as any)[key], (b as any)[key])
35
+ ) {
36
+ return false;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Convert derived state to public version (strips inflightActions)
44
+ */
45
+ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
46
+ const { inflightActions: _, ...publicState } = state;
47
+ return publicState;
48
+ }
49
+
50
+
51
+ /**
52
+ * Navigation methods returned by useNavigation
53
+ */
54
+ export interface NavigationMethods {
55
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
56
+ refresh: () => Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * Full value returned when no selector is provided
61
+ */
62
+ export type NavigationValue = PublicNavigationState & NavigationMethods;
63
+
64
+ /**
65
+ * Hook to access navigation state with optional selector for performance
66
+ *
67
+ * Uses the event controller for reactive state management.
68
+ * State is derived from source of truth (currentNavigation, inflightActions).
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * const state = useNavigation(nav => nav.state);
73
+ * const isLoading = useNavigation(nav => nav.state === 'loading');
74
+ * ```
75
+ */
76
+ export function useNavigation(): NavigationValue;
77
+ export function useNavigation<T>(
78
+ selector: (state: PublicNavigationState) => T,
79
+ ): T;
80
+ export function useNavigation<T>(
81
+ selector?: (state: PublicNavigationState) => T,
82
+ ): T | NavigationValue {
83
+ const ctx = useContext(NavigationStoreContext);
84
+
85
+ if (!ctx) {
86
+ throw new Error(
87
+ "useNavigation must be used within NavigationStoreContext.Provider"
88
+ );
89
+ }
90
+
91
+ // Base state for useOptimistic
92
+ const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
93
+ const publicState = toPublicState(ctx.eventController.getState());
94
+ return selector ? selector(publicState) : publicState;
95
+ });
96
+ const prevState = useRef(baseValue);
97
+
98
+ // useOptimistic allows immediate updates during transitions/actions
99
+ const [value, setOptimisticValue] = useOptimistic(baseValue);
100
+
101
+ // Subscribe to event controller state changes (only runs on client)
102
+ useEffect(() => {
103
+ // Subscribe to updates from event controller
104
+ return ctx.eventController.subscribe(() => {
105
+ const currentState = ctx.eventController.getState();
106
+ const publicState = toPublicState(currentState);
107
+ const nextSelected = selector ? selector(publicState) : publicState;
108
+
109
+ // Check if selected value has changed
110
+ if (!shallowEqual(nextSelected, prevState.current)) {
111
+ prevState.current = nextSelected;
112
+
113
+ // Check if any actions are in progress for optimistic updates
114
+ const hasInflightActions =
115
+ ctx.eventController.getInflightActions().size > 0;
116
+
117
+ if (hasInflightActions || publicState.state !== "idle") {
118
+ // Use optimistic update for immediate feedback during transitions
119
+ startTransition(() => {
120
+ setOptimisticValue(nextSelected);
121
+ });
122
+ }
123
+
124
+ // Always update base state so UI reflects current state
125
+ setBaseValue(nextSelected);
126
+ }
127
+ });
128
+ }, [selector]);
129
+
130
+ // If no selector, include navigation methods
131
+ if (!selector) {
132
+ return {
133
+ ...(value as PublicNavigationState),
134
+ navigate: ctx.navigate,
135
+ refresh: ctx.refresh,
136
+ };
137
+ }
138
+
139
+ return value as T;
140
+ }