@rangojs/router 0.0.0-experimental.2

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 (155) hide show
  1. package/CLAUDE.md +7 -0
  2. package/README.md +19 -0
  3. package/dist/vite/index.js +1298 -0
  4. package/package.json +140 -0
  5. package/skills/caching/SKILL.md +319 -0
  6. package/skills/document-cache/SKILL.md +152 -0
  7. package/skills/hooks/SKILL.md +359 -0
  8. package/skills/intercept/SKILL.md +292 -0
  9. package/skills/layout/SKILL.md +216 -0
  10. package/skills/loader/SKILL.md +365 -0
  11. package/skills/middleware/SKILL.md +442 -0
  12. package/skills/parallel/SKILL.md +255 -0
  13. package/skills/route/SKILL.md +141 -0
  14. package/skills/router-setup/SKILL.md +403 -0
  15. package/skills/theme/SKILL.md +54 -0
  16. package/skills/typesafety/SKILL.md +352 -0
  17. package/src/__mocks__/version.ts +6 -0
  18. package/src/__tests__/component-utils.test.ts +76 -0
  19. package/src/__tests__/route-definition.test.ts +63 -0
  20. package/src/__tests__/urls.test.tsx +436 -0
  21. package/src/browser/event-controller.ts +876 -0
  22. package/src/browser/index.ts +18 -0
  23. package/src/browser/link-interceptor.ts +121 -0
  24. package/src/browser/lru-cache.ts +69 -0
  25. package/src/browser/merge-segment-loaders.ts +126 -0
  26. package/src/browser/navigation-bridge.ts +893 -0
  27. package/src/browser/navigation-client.ts +162 -0
  28. package/src/browser/navigation-store.ts +823 -0
  29. package/src/browser/partial-update.ts +559 -0
  30. package/src/browser/react/Link.tsx +248 -0
  31. package/src/browser/react/NavigationProvider.tsx +275 -0
  32. package/src/browser/react/ScrollRestoration.tsx +94 -0
  33. package/src/browser/react/context.ts +53 -0
  34. package/src/browser/react/index.ts +52 -0
  35. package/src/browser/react/location-state-shared.ts +120 -0
  36. package/src/browser/react/location-state.ts +62 -0
  37. package/src/browser/react/use-action.ts +240 -0
  38. package/src/browser/react/use-client-cache.ts +56 -0
  39. package/src/browser/react/use-handle.ts +178 -0
  40. package/src/browser/react/use-href.tsx +208 -0
  41. package/src/browser/react/use-link-status.ts +134 -0
  42. package/src/browser/react/use-navigation.ts +150 -0
  43. package/src/browser/react/use-segments.ts +188 -0
  44. package/src/browser/request-controller.ts +164 -0
  45. package/src/browser/rsc-router.tsx +353 -0
  46. package/src/browser/scroll-restoration.ts +324 -0
  47. package/src/browser/server-action-bridge.ts +747 -0
  48. package/src/browser/shallow.ts +35 -0
  49. package/src/browser/types.ts +464 -0
  50. package/src/cache/__tests__/document-cache.test.ts +522 -0
  51. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  52. package/src/cache/__tests__/memory-store.test.ts +484 -0
  53. package/src/cache/cache-scope.ts +565 -0
  54. package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
  55. package/src/cache/cf/cf-cache-store.ts +428 -0
  56. package/src/cache/cf/index.ts +19 -0
  57. package/src/cache/document-cache.ts +340 -0
  58. package/src/cache/index.ts +58 -0
  59. package/src/cache/memory-segment-store.ts +150 -0
  60. package/src/cache/memory-store.ts +253 -0
  61. package/src/cache/types.ts +387 -0
  62. package/src/client.rsc.tsx +88 -0
  63. package/src/client.tsx +621 -0
  64. package/src/component-utils.ts +76 -0
  65. package/src/components/DefaultDocument.tsx +23 -0
  66. package/src/default-error-boundary.tsx +88 -0
  67. package/src/deps/browser.ts +8 -0
  68. package/src/deps/html-stream-client.ts +2 -0
  69. package/src/deps/html-stream-server.ts +2 -0
  70. package/src/deps/rsc.ts +10 -0
  71. package/src/deps/ssr.ts +2 -0
  72. package/src/errors.ts +259 -0
  73. package/src/handle.ts +120 -0
  74. package/src/handles/MetaTags.tsx +193 -0
  75. package/src/handles/index.ts +6 -0
  76. package/src/handles/meta.ts +247 -0
  77. package/src/href-client.ts +128 -0
  78. package/src/href-context.ts +33 -0
  79. package/src/href.ts +177 -0
  80. package/src/index.rsc.ts +79 -0
  81. package/src/index.ts +87 -0
  82. package/src/loader.rsc.ts +204 -0
  83. package/src/loader.ts +47 -0
  84. package/src/network-error-thrower.tsx +21 -0
  85. package/src/outlet-context.ts +15 -0
  86. package/src/root-error-boundary.tsx +277 -0
  87. package/src/route-content-wrapper.tsx +198 -0
  88. package/src/route-definition.ts +1371 -0
  89. package/src/route-map-builder.ts +146 -0
  90. package/src/route-types.ts +198 -0
  91. package/src/route-utils.ts +89 -0
  92. package/src/router/__tests__/match-context.test.ts +104 -0
  93. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  94. package/src/router/__tests__/match-result.test.ts +566 -0
  95. package/src/router/__tests__/on-error.test.ts +935 -0
  96. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  97. package/src/router/error-handling.ts +287 -0
  98. package/src/router/handler-context.ts +158 -0
  99. package/src/router/loader-resolution.ts +326 -0
  100. package/src/router/manifest.ts +138 -0
  101. package/src/router/match-context.ts +264 -0
  102. package/src/router/match-middleware/background-revalidation.ts +236 -0
  103. package/src/router/match-middleware/cache-lookup.ts +261 -0
  104. package/src/router/match-middleware/cache-store.ts +266 -0
  105. package/src/router/match-middleware/index.ts +81 -0
  106. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  107. package/src/router/match-middleware/segment-resolution.ts +174 -0
  108. package/src/router/match-pipelines.ts +214 -0
  109. package/src/router/match-result.ts +214 -0
  110. package/src/router/metrics.ts +62 -0
  111. package/src/router/middleware.test.ts +1355 -0
  112. package/src/router/middleware.ts +748 -0
  113. package/src/router/pattern-matching.ts +272 -0
  114. package/src/router/revalidation.ts +190 -0
  115. package/src/router/router-context.ts +299 -0
  116. package/src/router/types.ts +96 -0
  117. package/src/router.ts +3876 -0
  118. package/src/rsc/__tests__/helpers.test.ts +175 -0
  119. package/src/rsc/handler.ts +1060 -0
  120. package/src/rsc/helpers.ts +64 -0
  121. package/src/rsc/index.ts +56 -0
  122. package/src/rsc/nonce.ts +18 -0
  123. package/src/rsc/types.ts +237 -0
  124. package/src/segment-system.tsx +456 -0
  125. package/src/server/__tests__/request-context.test.ts +171 -0
  126. package/src/server/context.ts +417 -0
  127. package/src/server/handle-store.ts +230 -0
  128. package/src/server/loader-registry.ts +174 -0
  129. package/src/server/request-context.ts +554 -0
  130. package/src/server/root-layout.tsx +10 -0
  131. package/src/server/tsconfig.json +14 -0
  132. package/src/server.ts +146 -0
  133. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  134. package/src/ssr/index.tsx +234 -0
  135. package/src/theme/ThemeProvider.tsx +291 -0
  136. package/src/theme/ThemeScript.tsx +61 -0
  137. package/src/theme/__tests__/theme.test.ts +120 -0
  138. package/src/theme/constants.ts +55 -0
  139. package/src/theme/index.ts +58 -0
  140. package/src/theme/theme-context.ts +70 -0
  141. package/src/theme/theme-script.ts +152 -0
  142. package/src/theme/types.ts +182 -0
  143. package/src/theme/use-theme.ts +44 -0
  144. package/src/types.ts +1561 -0
  145. package/src/urls.ts +726 -0
  146. package/src/use-loader.tsx +346 -0
  147. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  148. package/src/vite/expose-action-id.ts +344 -0
  149. package/src/vite/expose-handle-id.ts +209 -0
  150. package/src/vite/expose-loader-id.ts +357 -0
  151. package/src/vite/expose-location-state-id.ts +177 -0
  152. package/src/vite/index.ts +787 -0
  153. package/src/vite/package-resolution.ts +125 -0
  154. package/src/vite/version.d.ts +12 -0
  155. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { createHref, type ScopedHrefFunction } from "../../href.js";
5
+ import type { UrlPatterns } from "../../urls.js";
6
+ import { HrefContext, type HrefContextValue } from "../../href-context.js";
7
+
8
+ // Re-export for backwards compatibility
9
+ export { HrefContext, type HrefContextValue } from "../../href-context.js";
10
+
11
+ /**
12
+ * Resolution priority for href:
13
+ * 1. Path-based (/blog/:slug) → Use directly
14
+ * 2. Absolute name (shop.cart) → Global lookup (has dot separator)
15
+ * 3. Local name (index) → Prepend current name prefix, then lookup
16
+ */
17
+ function resolveRouteName(
18
+ name: string,
19
+ routeMap: Record<string, string>,
20
+ currentRoutePrefix?: string
21
+ ): string | undefined {
22
+ // 1. Path-based - starts with /
23
+ if (name.startsWith("/")) {
24
+ return name;
25
+ }
26
+
27
+ // 2. Absolute name - already has a dot (e.g., "shop.cart")
28
+ if (name.includes(".")) {
29
+ return routeMap[name];
30
+ }
31
+
32
+ // 3. Local name - try with current prefix first, then fall back to direct lookup
33
+ if (currentRoutePrefix) {
34
+ // Extract the prefix from current route name
35
+ // e.g., "blog.posts.detail" → prefix is "blog.posts"
36
+ const lastDot = currentRoutePrefix.lastIndexOf(".");
37
+ const prefix = lastDot > 0 ? currentRoutePrefix.substring(0, lastDot) : currentRoutePrefix;
38
+
39
+ // Try prefixed name
40
+ const prefixedName = `${prefix}.${name}`;
41
+ if (routeMap[prefixedName] !== undefined) {
42
+ return routeMap[prefixedName];
43
+ }
44
+
45
+ // If current route is a nested include, try parent prefixes
46
+ // e.g., for "blog.posts.detail", try "blog.posts.index", then "blog.index"
47
+ let currentPrefix = prefix;
48
+ while (currentPrefix.includes(".")) {
49
+ const parentDot = currentPrefix.lastIndexOf(".");
50
+ currentPrefix = currentPrefix.substring(0, parentDot);
51
+ const parentPrefixedName = `${currentPrefix}.${name}`;
52
+ if (routeMap[parentPrefixedName] !== undefined) {
53
+ return routeMap[parentPrefixedName];
54
+ }
55
+ }
56
+ }
57
+
58
+ // Fall back to direct lookup (route without prefix)
59
+ return routeMap[name];
60
+ }
61
+
62
+ /**
63
+ * Type for href function returned by useHref
64
+ */
65
+ export type HrefFn = {
66
+ /**
67
+ * Generate a URL from a route name
68
+ *
69
+ * @param name - Route name (local or absolute) or path-based URL
70
+ * @param params - Optional params for dynamic segments
71
+ * @returns The resolved URL
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * const href = useHref();
76
+ *
77
+ * // Local name (resolved with current prefix)
78
+ * href("index") // → "/blog" (if inside blog patterns)
79
+ * href("post", { slug: "hello" }) // → "/blog/hello"
80
+ *
81
+ * // Absolute name (direct lookup)
82
+ * href("shop.cart") // → "/shop/cart"
83
+ *
84
+ * // Path-based (used directly)
85
+ * href("/about") // → "/about"
86
+ * ```
87
+ */
88
+ (name: string, params?: Record<string, string>): string;
89
+ };
90
+
91
+ /**
92
+ * Client-side hook for resolving route names with current name prefix.
93
+ *
94
+ * Resolution priority:
95
+ * 1. Path-based (`/blog/:slug`) → Use directly
96
+ * 2. Absolute name (`shop.cart`) → Global lookup (contains dot)
97
+ * 3. Local name (`index`) → Prepend current name prefix, then lookup
98
+ *
99
+ * @typeParam TPatterns - Optional patterns type for type-safe local route names
100
+ * @returns A function to generate URLs from route names
101
+ *
102
+ * @example
103
+ * ```tsx
104
+ * "use client";
105
+ * import { useHref } from "@rangojs/router/client";
106
+ * import { blogPatterns } from "../urls/blog";
107
+ *
108
+ * function BlogNav() {
109
+ * // Type-safe: knows "index" | "post" are valid local names
110
+ * const href = useHref<typeof blogPatterns>();
111
+ *
112
+ * return (
113
+ * <>
114
+ * {/* Local names - type-safe, resolved with current name prefix *\/}
115
+ * <Link href={href("index")}>Blog Home</Link>
116
+ * <Link href={href("post", { slug: "hello" })}>Post</Link>
117
+ *
118
+ * {/* Absolute names - explicit prefix *\/}
119
+ * <Link href={href("shop.cart")}>Cart</Link>
120
+ *
121
+ * {/* Path-based - always works *\/}
122
+ * <Link href={href("/about")}>About</Link>
123
+ * </>
124
+ * );
125
+ * }
126
+ * ```
127
+ */
128
+ export function useHref<
129
+ TPatterns extends UrlPatterns<any, any> = UrlPatterns<any, Record<string, string>>
130
+ >(): TPatterns extends UrlPatterns<any, infer TRoutes>
131
+ ? ScopedHrefFunction<TRoutes>
132
+ : HrefFn {
133
+ const context = useContext(HrefContext);
134
+
135
+ if (!context) {
136
+ // Return a function that warns and returns the name as-is
137
+ return ((name: string, _params?: Record<string, string>) => {
138
+ if (process.env.NODE_ENV !== "production") {
139
+ console.warn(
140
+ "[useHref] HrefContext not found. Make sure HrefProvider is mounted. Returning name as-is."
141
+ );
142
+ }
143
+ return name;
144
+ }) as any;
145
+ }
146
+
147
+ const { routeMap, routeName } = context;
148
+
149
+ return ((name: string, params?: Record<string, string>) => {
150
+ // Path-based - return directly (optionally with param substitution)
151
+ if (name.startsWith("/")) {
152
+ if (params) {
153
+ // Substitute params in path-based URL
154
+ return name.replace(/:([^/]+)/g, (_, key) => {
155
+ const value = params[key];
156
+ if (value === undefined) {
157
+ throw new Error(`Missing param "${key}" for path "${name}"`);
158
+ }
159
+ return encodeURIComponent(value);
160
+ });
161
+ }
162
+ return name;
163
+ }
164
+
165
+ // Resolve route name
166
+ const pattern = resolveRouteName(name, routeMap, routeName);
167
+
168
+ if (pattern === undefined) {
169
+ throw new Error(
170
+ `Unknown route: "${name}"${routeName ? ` (current route: ${routeName})` : ""}`
171
+ );
172
+ }
173
+
174
+ // If no params, return pattern directly
175
+ if (!params) {
176
+ return pattern;
177
+ }
178
+
179
+ // Substitute params
180
+ return pattern.replace(/:([^/]+)/g, (_, key) => {
181
+ const value = params[key];
182
+ if (value === undefined) {
183
+ throw new Error(`Missing param "${key}" for route "${name}"`);
184
+ }
185
+ return encodeURIComponent(value);
186
+ });
187
+ }) as any;
188
+ }
189
+
190
+ /**
191
+ * Provider component for href context
192
+ * Used internally by NavigationProvider to pass route map from RSC metadata
193
+ */
194
+ export function HrefProvider({
195
+ routeMap,
196
+ routeName,
197
+ children,
198
+ }: {
199
+ routeMap: Record<string, string>;
200
+ routeName?: string;
201
+ children: React.ReactNode;
202
+ }) {
203
+ return (
204
+ <HrefContext.Provider value={{ routeMap, routeName }}>
205
+ {children}
206
+ </HrefContext.Provider>
207
+ );
208
+ }
@@ -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,150 @@
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
+ // SSR-safe default state (public version without internal properties)
43
+ const SSR_DEFAULT_STATE: PublicNavigationState = {
44
+ state: "idle",
45
+ isStreaming: false,
46
+ location: new URL("/", "http://localhost"),
47
+ pendingUrl: null,
48
+ };
49
+
50
+ /**
51
+ * Convert derived state to public version (strips inflightActions)
52
+ */
53
+ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
54
+ const { inflightActions: _, ...publicState } = state;
55
+ return publicState;
56
+ }
57
+
58
+ // No-op functions for SSR
59
+ const noopNavigate = async () => {};
60
+ const noopRefresh = async () => {};
61
+
62
+ /**
63
+ * Navigation methods returned by useNavigation
64
+ */
65
+ export interface NavigationMethods {
66
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
67
+ refresh: () => Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Full value returned when no selector is provided
72
+ */
73
+ export type NavigationValue = PublicNavigationState & NavigationMethods;
74
+
75
+ /**
76
+ * Hook to access navigation state with optional selector for performance
77
+ *
78
+ * Uses the event controller for reactive state management.
79
+ * State is derived from source of truth (currentNavigation, inflightActions).
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * const state = useNavigation(nav => nav.state);
84
+ * const isLoading = useNavigation(nav => nav.state === 'loading');
85
+ * ```
86
+ */
87
+ export function useNavigation(): NavigationValue;
88
+ export function useNavigation<T>(
89
+ selector: (state: PublicNavigationState) => T
90
+ ): T;
91
+ export function useNavigation<T>(
92
+ selector?: (state: PublicNavigationState) => T
93
+ ): T | NavigationValue {
94
+ const ctx = useContext(NavigationStoreContext);
95
+
96
+ // Base state for useOptimistic
97
+ const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
98
+ if (typeof document === "undefined" || !ctx) {
99
+ return selector ? selector(SSR_DEFAULT_STATE) : SSR_DEFAULT_STATE;
100
+ }
101
+ const publicState = toPublicState(ctx.eventController.getState());
102
+ return selector ? selector(publicState) : publicState;
103
+ });
104
+ const prevState = useRef(baseValue);
105
+
106
+ // useOptimistic allows immediate updates during transitions/actions
107
+ const [value, setOptimisticValue] = useOptimistic(baseValue);
108
+
109
+ // Subscribe to event controller state changes (only runs on client)
110
+ useEffect(() => {
111
+ if (!ctx) return;
112
+
113
+ // Subscribe to updates from event controller
114
+ return ctx.eventController.subscribe(() => {
115
+ const currentState = ctx.eventController.getState();
116
+ const publicState = toPublicState(currentState);
117
+ const nextSelected = selector ? selector(publicState) : publicState;
118
+
119
+ // Check if selected value has changed
120
+ if (!shallowEqual(nextSelected, prevState.current)) {
121
+ prevState.current = nextSelected;
122
+
123
+ // Check if any actions are in progress for optimistic updates
124
+ const hasInflightActions =
125
+ ctx.eventController.getInflightActions().size > 0;
126
+
127
+ if (hasInflightActions || publicState.state !== "idle") {
128
+ // Use optimistic update for immediate feedback during transitions
129
+ startTransition(() => {
130
+ setOptimisticValue(nextSelected);
131
+ });
132
+ }
133
+
134
+ // Always update base state so UI reflects current state
135
+ setBaseValue(nextSelected);
136
+ }
137
+ });
138
+ }, [selector]);
139
+
140
+ // If no selector, include navigation methods
141
+ if (!selector) {
142
+ return {
143
+ ...(value as PublicNavigationState),
144
+ navigate: ctx?.navigate ?? noopNavigate,
145
+ refresh: ctx?.refresh ?? noopRefresh,
146
+ };
147
+ }
148
+
149
+ return value as T;
150
+ }
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ import { useContext, useState, useEffect, useRef } from "react";
4
+ import { NavigationStoreContext } from "./context.js";
5
+
6
+ /**
7
+ * Segments state returned by useSegments hook
8
+ */
9
+ export interface SegmentsState {
10
+ /** URL path segments (e.g., /shop/products/123 → ["shop", "products", "123"]) */
11
+ path: readonly string[];
12
+ /** Matched segment IDs in order (layouts and routes only, e.g., ["L0", "L0L1", "L0L1R0"]) */
13
+ segmentIds: readonly string[];
14
+ /** Current URL location */
15
+ location: URL;
16
+ }
17
+
18
+ /**
19
+ * SSR module-level state.
20
+ * Populated by initSegmentsSync before React renders.
21
+ * Used by useState initializer during SSR.
22
+ */
23
+ let ssrSegmentOrder: string[] = [];
24
+ let ssrPathname: string = "/";
25
+
26
+ /**
27
+ * Filter segment IDs to only include routes and layouts.
28
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
29
+ */
30
+ function filterSegmentOrder(matched: string[]): string[] {
31
+ return matched.filter((id) => {
32
+ if (id.includes(".@")) return false;
33
+ if (/D\d+\./.test(id)) return false;
34
+ return true;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Initialize segments data synchronously for SSR.
40
+ * Called before rendering to populate state for useState initializer.
41
+ *
42
+ * @param matched - Segment order from RSC metadata
43
+ * @param pathname - Current pathname
44
+ */
45
+ export function initSegmentsSync(matched?: string[], pathname?: string): void {
46
+ ssrSegmentOrder = filterSegmentOrder(matched ?? []);
47
+ ssrPathname = pathname ?? "/";
48
+ }
49
+
50
+ /**
51
+ * Shallow equality check for selector results
52
+ */
53
+ function shallowEqual<T>(a: T, b: T): boolean {
54
+ if (Object.is(a, b)) return true;
55
+ if (
56
+ typeof a !== "object" ||
57
+ a === null ||
58
+ typeof b !== "object" ||
59
+ b === null
60
+ ) {
61
+ return false;
62
+ }
63
+ const keysA = Object.keys(a);
64
+ const keysB = Object.keys(b);
65
+ if (keysA.length !== keysB.length) return false;
66
+ for (const key of keysA) {
67
+ if (
68
+ !Object.hasOwn(b, key) ||
69
+ !Object.is((a as any)[key], (b as any)[key])
70
+ ) {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Parse pathname into path segments
79
+ * /shop/products/123 → ["shop", "products", "123"]
80
+ */
81
+ function parsePathname(pathname: string): string[] {
82
+ return pathname.split("/").filter(Boolean);
83
+ }
84
+
85
+ /**
86
+ * Build segments state from event controller
87
+ */
88
+ function buildSegmentsState(
89
+ location: URL,
90
+ segmentOrder: string[]
91
+ ): SegmentsState {
92
+ return {
93
+ path: parsePathname(location.pathname),
94
+ segmentIds: segmentOrder,
95
+ location,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build SSR state from module-level variables
101
+ */
102
+ function buildSsrState(): SegmentsState {
103
+ const location = new URL(ssrPathname, "http://localhost");
104
+ return {
105
+ path: parsePathname(ssrPathname),
106
+ segmentIds: ssrSegmentOrder,
107
+ location,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Hook to access current route segments with optional selector for performance
113
+ *
114
+ * Provides information about the current URL path and matched route segments.
115
+ * Uses the event controller for reactive state management.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Get full segments state
120
+ * const { path, segmentIds, location } = useSegments();
121
+ *
122
+ * // Use selector for specific values (better performance)
123
+ * const path = useSegments(s => s.path);
124
+ * const isShopRoute = useSegments(s => s.path[0] === "shop");
125
+ * ```
126
+ */
127
+ export function useSegments(): SegmentsState;
128
+ export function useSegments<T>(selector: (state: SegmentsState) => T): T;
129
+ export function useSegments<T>(
130
+ selector?: (state: SegmentsState) => T
131
+ ): T | SegmentsState {
132
+ const ctx = useContext(NavigationStoreContext);
133
+
134
+ // Build initial state from SSR module state or event controller
135
+ const [state, setState] = useState<T | SegmentsState>(() => {
136
+ // During SSR or when no context, use module-level SSR state
137
+ if (typeof document === "undefined" || !ctx) {
138
+ const ssrState = buildSsrState();
139
+ return selector ? selector(ssrState) : ssrState;
140
+ }
141
+ // On client with context, use event controller state
142
+ const navState = ctx.eventController.getState();
143
+ const handleState = ctx.eventController.getHandleState();
144
+ const segmentsState = buildSegmentsState(
145
+ navState.location as URL,
146
+ handleState.segmentOrder
147
+ );
148
+ return selector ? selector(segmentsState) : segmentsState;
149
+ });
150
+
151
+ const prevState = useRef(state);
152
+
153
+ // Subscribe to both navigation state and handle state changes
154
+ useEffect(() => {
155
+ if (!ctx) {
156
+ return;
157
+ }
158
+
159
+ const updateState = () => {
160
+ const navState = ctx.eventController.getState();
161
+ const handleState = ctx.eventController.getHandleState();
162
+ const segmentsState = buildSegmentsState(
163
+ navState.location as URL,
164
+ handleState.segmentOrder
165
+ );
166
+ const nextSelected = selector ? selector(segmentsState) : segmentsState;
167
+
168
+ if (!shallowEqual(nextSelected, prevState.current)) {
169
+ prevState.current = nextSelected;
170
+ setState(nextSelected);
171
+ }
172
+ };
173
+
174
+ // Initial update in case SSR state differs from client state
175
+ updateState();
176
+
177
+ // Subscribe to both state sources
178
+ const unsubscribeNav = ctx.eventController.subscribe(updateState);
179
+ const unsubscribeHandles = ctx.eventController.subscribeToHandles(updateState);
180
+
181
+ return () => {
182
+ unsubscribeNav();
183
+ unsubscribeHandles();
184
+ };
185
+ }, [selector]);
186
+
187
+ return state as T | SegmentsState;
188
+ }