@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.
- package/CLAUDE.md +7 -0
- package/README.md +19 -0
- package/dist/vite/index.js +1298 -0
- package/package.json +140 -0
- package/skills/caching/SKILL.md +319 -0
- package/skills/document-cache/SKILL.md +152 -0
- package/skills/hooks/SKILL.md +359 -0
- package/skills/intercept/SKILL.md +292 -0
- package/skills/layout/SKILL.md +216 -0
- package/skills/loader/SKILL.md +365 -0
- package/skills/middleware/SKILL.md +442 -0
- package/skills/parallel/SKILL.md +255 -0
- package/skills/route/SKILL.md +141 -0
- package/skills/router-setup/SKILL.md +403 -0
- package/skills/theme/SKILL.md +54 -0
- package/skills/typesafety/SKILL.md +352 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/component-utils.test.ts +76 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/__tests__/urls.test.tsx +436 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +893 -0
- package/src/browser/navigation-client.ts +162 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +559 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +275 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-href.tsx +208 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +353 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +464 -0
- package/src/cache/__tests__/document-cache.test.ts +522 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +387 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +621 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href-context.ts +33 -0
- package/src/href.ts +177 -0
- package/src/index.rsc.ts +79 -0
- package/src/index.ts +87 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1371 -0
- package/src/route-map-builder.ts +146 -0
- package/src/route-types.ts +198 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +138 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +266 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +214 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +272 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3876 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +1060 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +237 -0
- package/src/segment-system.tsx +456 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +417 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +146 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +234 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/__tests__/theme.test.ts +120 -0
- package/src/theme/constants.ts +55 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1561 -0
- package/src/urls.ts +726 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +787 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- 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
|
+
}
|