@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.
- package/CLAUDE.md +43 -0
- package/README.md +19 -0
- package/dist/bin/rango.js +227 -0
- package/dist/vite/index.js +3039 -0
- package/package.json +171 -0
- package/skills/caching/SKILL.md +191 -0
- package/skills/debug-manifest/SKILL.md +108 -0
- package/skills/document-cache/SKILL.md +180 -0
- package/skills/fonts/SKILL.md +165 -0
- package/skills/hooks/SKILL.md +442 -0
- package/skills/intercept/SKILL.md +190 -0
- package/skills/layout/SKILL.md +213 -0
- package/skills/links/SKILL.md +180 -0
- package/skills/loader/SKILL.md +246 -0
- package/skills/middleware/SKILL.md +202 -0
- package/skills/mime-routes/SKILL.md +124 -0
- package/skills/parallel/SKILL.md +228 -0
- package/skills/prerender/SKILL.md +283 -0
- package/skills/rango/SKILL.md +54 -0
- package/skills/response-routes/SKILL.md +358 -0
- package/skills/route/SKILL.md +173 -0
- package/skills/router-setup/SKILL.md +346 -0
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +78 -0
- package/skills/typesafety/SKILL.md +394 -0
- package/src/__internal.ts +175 -0
- package/src/bin/rango.ts +24 -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 +913 -0
- package/src/browser/navigation-client.ts +165 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +600 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +346 -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/mount-context.ts +32 -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 +203 -0
- package/src/browser/react/use-href.tsx +40 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-mount.ts +31 -0
- package/src/browser/react/use-navigation.ts +140 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +352 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/segment-structure-assert.ts +67 -0
- package/src/browser/server-action-bridge.ts +762 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +478 -0
- package/src/build/generate-manifest.ts +377 -0
- package/src/build/generate-route-types.ts +828 -0
- package/src/build/index.ts +36 -0
- package/src/build/route-trie.ts +239 -0
- package/src/cache/cache-scope.ts +563 -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 +392 -0
- package/src/client.rsc.tsx +83 -0
- package/src/client.tsx +643 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/debug.ts +233 -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 +295 -0
- package/src/handle.ts +130 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/host/cookie-handler.ts +159 -0
- package/src/host/errors.ts +97 -0
- package/src/host/index.ts +56 -0
- package/src/host/pattern-matcher.ts +214 -0
- package/src/host/router.ts +330 -0
- package/src/host/testing.ts +79 -0
- package/src/host/types.ts +138 -0
- package/src/host/utils.ts +25 -0
- package/src/href-client.ts +202 -0
- package/src/href-context.ts +33 -0
- package/src/index.rsc.ts +121 -0
- package/src/index.ts +165 -0
- package/src/loader.rsc.ts +207 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/prerender/param-hash.ts +35 -0
- package/src/prerender/store.ts +40 -0
- package/src/prerender.ts +156 -0
- package/src/reverse.ts +267 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +193 -0
- package/src/route-definition.ts +1431 -0
- package/src/route-map-builder.ts +242 -0
- package/src/route-types.ts +220 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/intercept-resolution.ts +387 -0
- package/src/router/loader-resolution.ts +327 -0
- package/src/router/manifest.ts +216 -0
- package/src/router/match-api.ts +621 -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 +382 -0
- package/src/router/match-middleware/cache-store.ts +276 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +281 -0
- package/src/router/match-middleware/segment-resolution.ts +184 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +213 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.ts +791 -0
- package/src/router/pattern-matching.ts +407 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +301 -0
- package/src/router/segment-resolution.ts +1315 -0
- package/src/router/trie-matching.ts +172 -0
- package/src/router/types.ts +163 -0
- package/src/router.gen.ts +6 -0
- package/src/router.ts +2423 -0
- package/src/rsc/handler.ts +1443 -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 +236 -0
- package/src/segment-system.tsx +442 -0
- package/src/server/context.ts +466 -0
- package/src/server/handle-store.ts +229 -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 +171 -0
- package/src/ssr/index.tsx +296 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/constants.ts +59 -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 +1757 -0
- package/src/urls.gen.ts +8 -0
- package/src/urls.ts +1282 -0
- package/src/use-loader.tsx +346 -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 +426 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/expose-prerender-handler-id.ts +429 -0
- package/src/vite/index.ts +2068 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +114 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { OutletContext, type OutletContextValue } from "./outlet-context.js";
|
|
5
|
+
import type { LoaderDefinition, LoadOptions } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Payload returned by loader RSC requests
|
|
9
|
+
*/
|
|
10
|
+
interface LoaderRscPayload<T = unknown> {
|
|
11
|
+
loaderResult: T;
|
|
12
|
+
loaderError?: { message: string; name: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load function type with form action support
|
|
17
|
+
*/
|
|
18
|
+
export type LoadFunction<T> = ((options?: LoadOptions) => Promise<T>) & {
|
|
19
|
+
/** Form action for progressive enhancement - can be passed to form action prop */
|
|
20
|
+
action: (formData: FormData) => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result type for useLoader hook (strict - data is required)
|
|
25
|
+
*/
|
|
26
|
+
export interface UseLoaderResult<T> {
|
|
27
|
+
/** The loaded data - guaranteed to exist when loader is registered on route */
|
|
28
|
+
data: T;
|
|
29
|
+
/** True while a load() is in progress */
|
|
30
|
+
isLoading: boolean;
|
|
31
|
+
/** Error from the most recent load attempt, null if successful */
|
|
32
|
+
error: Error | null;
|
|
33
|
+
/** Function to trigger a fetch (only works if loader is fetchable) */
|
|
34
|
+
load: LoadFunction<T>;
|
|
35
|
+
/** Alias for load */
|
|
36
|
+
refetch: LoadFunction<T>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result type for useFetchLoader hook (flexible - data is optional)
|
|
41
|
+
*/
|
|
42
|
+
export interface UseFetchLoaderResult<T> {
|
|
43
|
+
/** The loaded data - may be undefined if not yet fetched or not in context */
|
|
44
|
+
data: T | undefined;
|
|
45
|
+
/** True while a load() is in progress */
|
|
46
|
+
isLoading: boolean;
|
|
47
|
+
/** Error from the most recent load attempt, null if successful */
|
|
48
|
+
error: Error | null;
|
|
49
|
+
/** Function to trigger a fetch (only works if loader is fetchable) */
|
|
50
|
+
load: LoadFunction<T>;
|
|
51
|
+
/** Alias for load */
|
|
52
|
+
refetch: LoadFunction<T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for useLoader hook
|
|
57
|
+
*/
|
|
58
|
+
export interface UseLoaderOptions {
|
|
59
|
+
/**
|
|
60
|
+
* If true (default), errors from load() will be thrown to the nearest error boundary.
|
|
61
|
+
* If false, errors are only captured in the `error` state.
|
|
62
|
+
* @default true
|
|
63
|
+
*/
|
|
64
|
+
throwOnError?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Internal hook implementation shared by useLoader and useFetchLoader
|
|
69
|
+
*/
|
|
70
|
+
function useLoaderInternal<T>(
|
|
71
|
+
loader: LoaderDefinition<T>,
|
|
72
|
+
options?: UseLoaderOptions
|
|
73
|
+
): UseFetchLoaderResult<T> {
|
|
74
|
+
const context = useContext(OutletContext);
|
|
75
|
+
|
|
76
|
+
// Get data from context (SSR/navigation)
|
|
77
|
+
const getContextData = useCallback((): T | undefined => {
|
|
78
|
+
let current: OutletContextValue | null | undefined = context;
|
|
79
|
+
while (current) {
|
|
80
|
+
if (current.loaderData && loader.$$id in current.loaderData) {
|
|
81
|
+
return current.loaderData[loader.$$id] as T;
|
|
82
|
+
}
|
|
83
|
+
current = current.parent;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}, [context, loader.$$id]);
|
|
87
|
+
|
|
88
|
+
const contextData = getContextData();
|
|
89
|
+
|
|
90
|
+
// Local state for fetched data (from load() calls)
|
|
91
|
+
const [fetchedData, setFetchedData] = useState<T | undefined>(undefined);
|
|
92
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
93
|
+
const [error, setError] = useState<Error | null>(null);
|
|
94
|
+
|
|
95
|
+
// Track context data changes to reset fetched data on navigation
|
|
96
|
+
const prevContextDataRef = useRef(contextData);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (prevContextDataRef.current !== contextData) {
|
|
99
|
+
// Navigation happened, clear fetched data so context takes precedence
|
|
100
|
+
setFetchedData(undefined);
|
|
101
|
+
setError(null);
|
|
102
|
+
prevContextDataRef.current = contextData;
|
|
103
|
+
}
|
|
104
|
+
}, [contextData]);
|
|
105
|
+
|
|
106
|
+
// Data priority: fetched data (if any) > context data
|
|
107
|
+
const data = fetchedData ?? contextData;
|
|
108
|
+
|
|
109
|
+
const throwOnError = options?.throwOnError ?? true;
|
|
110
|
+
|
|
111
|
+
// Load function for fetching data
|
|
112
|
+
const load = useCallback(
|
|
113
|
+
async (loadOptions?: LoadOptions): Promise<T> => {
|
|
114
|
+
// Verify the loader has $$id
|
|
115
|
+
if (!loader.$$id) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Loader is missing $$id. Make sure the exposeLoaderId Vite plugin is enabled.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setIsLoading(true);
|
|
122
|
+
setError(null);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(window.location.pathname, window.location.origin);
|
|
126
|
+
url.searchParams.set("_rsc_loader", loader.$$id);
|
|
127
|
+
|
|
128
|
+
const method = loadOptions?.method ?? "GET";
|
|
129
|
+
const isBodyMethod = method !== "GET";
|
|
130
|
+
|
|
131
|
+
let fetchOptions: RequestInit;
|
|
132
|
+
|
|
133
|
+
if (isBodyMethod) {
|
|
134
|
+
// POST/PUT/PATCH/DELETE - send params and body as JSON
|
|
135
|
+
const bodyPayload: { params?: Record<string, string>; body?: unknown } = {};
|
|
136
|
+
if (loadOptions?.params && Object.keys(loadOptions.params).length > 0) {
|
|
137
|
+
bodyPayload.params = loadOptions.params;
|
|
138
|
+
}
|
|
139
|
+
if ("body" in (loadOptions ?? {}) && (loadOptions as any).body !== undefined) {
|
|
140
|
+
bodyPayload.body = (loadOptions as any).body;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fetchOptions = {
|
|
144
|
+
method,
|
|
145
|
+
headers: {
|
|
146
|
+
Accept: "text/x-component",
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify(bodyPayload),
|
|
150
|
+
};
|
|
151
|
+
} else {
|
|
152
|
+
// GET - send params in query string
|
|
153
|
+
if (loadOptions?.params && Object.keys(loadOptions.params).length > 0) {
|
|
154
|
+
url.searchParams.set(
|
|
155
|
+
"_rsc_loader_params",
|
|
156
|
+
JSON.stringify(loadOptions.params)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fetchOptions = {
|
|
161
|
+
method: "GET",
|
|
162
|
+
headers: {
|
|
163
|
+
Accept: "text/x-component",
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const response = fetch(url.toString(), fetchOptions);
|
|
169
|
+
|
|
170
|
+
const { createFromFetch } = await import("./deps/browser.js");
|
|
171
|
+
const payload = await createFromFetch<LoaderRscPayload<T>>(response);
|
|
172
|
+
|
|
173
|
+
if (payload.loaderError) {
|
|
174
|
+
throw new Error(payload.loaderError.message);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = payload.loaderResult;
|
|
178
|
+
setFetchedData(result);
|
|
179
|
+
return result;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
182
|
+
setError(err);
|
|
183
|
+
if (throwOnError) {
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
// When throwOnError is false, return the current data (previous successful
|
|
187
|
+
// value or undefined). Caller should check error state for error handling.
|
|
188
|
+
return data as T;
|
|
189
|
+
} finally {
|
|
190
|
+
setIsLoading(false);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
[throwOnError]
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Form action for progressive enhancement
|
|
197
|
+
// This wrapper is for useFetchLoader's load.action - it manages state internally
|
|
198
|
+
// and doesn't use React's useActionState. For true PE, use loader.action directly
|
|
199
|
+
// with useActionState.
|
|
200
|
+
const action = useCallback(
|
|
201
|
+
async (formData: FormData): Promise<void> => {
|
|
202
|
+
if (!loader.action) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Loader "${loader.$$id}" does not have an action. ` +
|
|
205
|
+
`Make sure the loader is created with fetchable: true.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
setIsLoading(true);
|
|
210
|
+
setError(null);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// Pass null as prevState - this wrapper manages state internally
|
|
214
|
+
const result = await loader.action(null, formData);
|
|
215
|
+
setFetchedData(result);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
218
|
+
setError(err);
|
|
219
|
+
if (throwOnError) {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
setIsLoading(false);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
[throwOnError]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Attach action to load function
|
|
230
|
+
const loadWithAction = load as LoadFunction<T>;
|
|
231
|
+
loadWithAction.action = action;
|
|
232
|
+
|
|
233
|
+
// Throw during render if there's an error and throwOnError is true
|
|
234
|
+
// This allows ErrorBoundaries to catch async errors from load()
|
|
235
|
+
if (error && throwOnError) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
data,
|
|
241
|
+
isLoading,
|
|
242
|
+
error,
|
|
243
|
+
load: loadWithAction,
|
|
244
|
+
refetch: loadWithAction,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Hook to access loader data from route context (strict version)
|
|
250
|
+
*
|
|
251
|
+
* Use this when the loader is registered on the route via `loader()`.
|
|
252
|
+
* The data is guaranteed to exist - throws an error if not found.
|
|
253
|
+
*
|
|
254
|
+
* For on-demand fetching or when loader might not be in context,
|
|
255
|
+
* use `useFetchLoader` instead.
|
|
256
|
+
*
|
|
257
|
+
* @param loader - The loader definition (must be registered on route)
|
|
258
|
+
* @param options - Optional configuration
|
|
259
|
+
* @returns Object with data (guaranteed), isLoading, error, load, and refetch
|
|
260
|
+
* @throws Error if loader data is not found in context
|
|
261
|
+
*
|
|
262
|
+
* @example Basic usage - accessing route loader data
|
|
263
|
+
* ```tsx
|
|
264
|
+
* "use client";
|
|
265
|
+
* import { useLoader } from "rsc-router/client";
|
|
266
|
+
* import { CartLoader } from "../loaders/cart";
|
|
267
|
+
*
|
|
268
|
+
* // In route definition: loader(CartLoader)
|
|
269
|
+
*
|
|
270
|
+
* export function CartIcon() {
|
|
271
|
+
* const { data } = useLoader(CartLoader);
|
|
272
|
+
* // data is guaranteed to be CartData, not CartData | undefined
|
|
273
|
+
* return <span>Cart ({data.items.length})</span>;
|
|
274
|
+
* }
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
export function useLoader<T>(
|
|
278
|
+
loader: LoaderDefinition<T>,
|
|
279
|
+
options?: UseLoaderOptions
|
|
280
|
+
): UseLoaderResult<T> {
|
|
281
|
+
const result = useLoaderInternal(loader, options);
|
|
282
|
+
|
|
283
|
+
// Strict mode: throw if data is not in context
|
|
284
|
+
if (result.data === undefined) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`useLoader: Loader "${loader.$$id}" data not found in context. ` +
|
|
287
|
+
`Make sure the loader is registered on the route with loader(). ` +
|
|
288
|
+
`If you need on-demand fetching, use useFetchLoader() instead.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return result as UseLoaderResult<T>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Hook to access loader data with optional fetching (flexible version)
|
|
297
|
+
*
|
|
298
|
+
* Use this when:
|
|
299
|
+
* - The loader might not be registered on the route
|
|
300
|
+
* - You want to fetch data on-demand from the client
|
|
301
|
+
* - You're building a reusable component that doesn't assume route context
|
|
302
|
+
*
|
|
303
|
+
* If the loader IS registered on the route, it will still get the initial
|
|
304
|
+
* data from context - you just have to handle the `undefined` case in types.
|
|
305
|
+
*
|
|
306
|
+
* @param loader - The loader definition
|
|
307
|
+
* @param options - Optional configuration
|
|
308
|
+
* @returns Object with data (may be undefined), isLoading, error, load, and refetch
|
|
309
|
+
*
|
|
310
|
+
* @example On-demand fetching
|
|
311
|
+
* ```tsx
|
|
312
|
+
* "use client";
|
|
313
|
+
* import { useFetchLoader } from "rsc-router/client";
|
|
314
|
+
* import { SearchLoader } from "../loaders/search";
|
|
315
|
+
*
|
|
316
|
+
* export function SearchResults() {
|
|
317
|
+
* const { data, load, isLoading } = useFetchLoader(SearchLoader);
|
|
318
|
+
*
|
|
319
|
+
* const handleSearch = async (query: string) => {
|
|
320
|
+
* await load({ params: { query } });
|
|
321
|
+
* };
|
|
322
|
+
*
|
|
323
|
+
* return (
|
|
324
|
+
* <div>
|
|
325
|
+
* <button onClick={() => handleSearch("test")}>Search</button>
|
|
326
|
+
* {isLoading && <span>Loading...</span>}
|
|
327
|
+
* {data?.results.map(r => <div key={r.id}>{r.name}</div>)}
|
|
328
|
+
* </div>
|
|
329
|
+
* );
|
|
330
|
+
* }
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* @example With route context (hybrid usage)
|
|
334
|
+
* ```tsx
|
|
335
|
+
* // Loader registered on route: loader(UserLoader)
|
|
336
|
+
* // useFetchLoader still works - gets initial data from context
|
|
337
|
+
* const { data, load } = useFetchLoader(UserLoader);
|
|
338
|
+
* // data is UserData | undefined (even though it will have initial value)
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export function useFetchLoader<T>(
|
|
342
|
+
loader: LoaderDefinition<T>,
|
|
343
|
+
options?: UseLoaderOptions
|
|
344
|
+
): UseFetchLoaderResult<T> {
|
|
345
|
+
return useLoaderInternal(loader, options);
|
|
346
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig } from "vite";
|
|
2
|
+
import MagicString from "magic-string";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Type for the RSC plugin's manager API
|
|
8
|
+
*/
|
|
9
|
+
interface RscPluginManager {
|
|
10
|
+
serverReferenceMetaMap: Record<
|
|
11
|
+
string,
|
|
12
|
+
{
|
|
13
|
+
importId: string;
|
|
14
|
+
referenceKey: string;
|
|
15
|
+
exportNames: string[];
|
|
16
|
+
}
|
|
17
|
+
>;
|
|
18
|
+
config: ResolvedConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RscPluginApi {
|
|
22
|
+
manager: RscPluginManager;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the RSC plugin's API from Vite config
|
|
27
|
+
*/
|
|
28
|
+
function getRscPluginApi(config: ResolvedConfig): RscPluginApi | undefined {
|
|
29
|
+
// Try by name first
|
|
30
|
+
let plugin = config.plugins.find((p) => p.name === "rsc:minimal");
|
|
31
|
+
|
|
32
|
+
// Fallback: find by API structure if name lookup fails
|
|
33
|
+
if (!plugin) {
|
|
34
|
+
plugin = config.plugins.find(
|
|
35
|
+
(p) =>
|
|
36
|
+
(p.api as RscPluginApi | undefined)?.manager?.serverReferenceMetaMap !==
|
|
37
|
+
undefined
|
|
38
|
+
);
|
|
39
|
+
if (plugin) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`[rsc-router:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
|
|
42
|
+
`Consider updating the name lookup if the plugin was renamed.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return plugin?.api as RscPluginApi | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize path to forward slashes
|
|
52
|
+
*/
|
|
53
|
+
function normalizePath(p: string): string {
|
|
54
|
+
return p.split(path.sep).join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a file is a "use server" module (has the directive at the module level).
|
|
59
|
+
* This distinguishes module-level server action files from files with inline actions.
|
|
60
|
+
*
|
|
61
|
+
* Module-level "use server" files should have their hash replaced with file paths
|
|
62
|
+
* for revalidation matching. Inline actions (defined in RSC components) should
|
|
63
|
+
* keep their hashed IDs for client security.
|
|
64
|
+
*/
|
|
65
|
+
function isUseServerModule(filePath: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
68
|
+
// Remove leading comments and whitespace to find the first meaningful content
|
|
69
|
+
const trimmed = content
|
|
70
|
+
.replace(/^\s*\/\/[^\n]*\n/gm, "") // Remove single-line comments
|
|
71
|
+
.replace(/^\s*\/\*[\s\S]*?\*\/\s*/gm, "") // Remove multi-line comments
|
|
72
|
+
.trimStart();
|
|
73
|
+
|
|
74
|
+
// Check if the file starts with "use server" directive
|
|
75
|
+
return (
|
|
76
|
+
trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'")
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Transform code to expose action IDs on createServerReference calls.
|
|
85
|
+
* Wraps each call with an IIFE that attaches $id to the returned function.
|
|
86
|
+
*
|
|
87
|
+
* @param code - The source code to transform
|
|
88
|
+
* @param sourceId - The source file identifier (for sourcemap)
|
|
89
|
+
* @param hashToFileMap - Optional mapping from hash to file path (for server bundles)
|
|
90
|
+
*/
|
|
91
|
+
function transformServerReferences(
|
|
92
|
+
code: string,
|
|
93
|
+
sourceId?: string,
|
|
94
|
+
hashToFileMap?: Map<string, string>
|
|
95
|
+
): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
|
|
96
|
+
if (!code.includes("createServerReference(")) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Match: createServerReference("hash#actionName", ...) or $$ReactClient.createServerReference(...)
|
|
101
|
+
// The RSC plugin uses $$ReactClient namespace in transformed code
|
|
102
|
+
const pattern =
|
|
103
|
+
/((?:\$\$\w+\.)?createServerReference)\(("[^"]+#[^"]+")([^)]*)\)/g;
|
|
104
|
+
|
|
105
|
+
const s = new MagicString(code);
|
|
106
|
+
let hasChanges = false;
|
|
107
|
+
let match: RegExpExecArray | null;
|
|
108
|
+
|
|
109
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
110
|
+
hasChanges = true;
|
|
111
|
+
const [fullMatch, fnCall, idArg, rest] = match;
|
|
112
|
+
const start = match.index;
|
|
113
|
+
const end = start + fullMatch.length;
|
|
114
|
+
|
|
115
|
+
// Parse the ID to potentially replace hash with file path
|
|
116
|
+
let finalIdArg = idArg;
|
|
117
|
+
if (hashToFileMap) {
|
|
118
|
+
// idArg is like '"hash#actionName"', extract the parts
|
|
119
|
+
const idValue = idArg.slice(1, -1); // Remove quotes
|
|
120
|
+
const hashMatch = idValue.match(/^([^#]+)#(.+)$/);
|
|
121
|
+
if (hashMatch) {
|
|
122
|
+
const [, hash, actionName] = hashMatch;
|
|
123
|
+
const filePath = hashToFileMap.get(hash);
|
|
124
|
+
if (filePath) {
|
|
125
|
+
// Replace hash with file path for server-side
|
|
126
|
+
finalIdArg = `"${filePath}#${actionName}"`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Wrap the createServerReference call to attach $$id to the returned function
|
|
132
|
+
const replacement = `(function(fn) { fn.$$id = ${finalIdArg}; return fn; })(${fnCall}(${idArg}${rest}))`;
|
|
133
|
+
s.overwrite(start, end, replacement);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!hasChanges) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
code: s.toString(),
|
|
142
|
+
map: s.generateMap({ source: sourceId, includeContent: true }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Transform registerServerReference calls in server bundles to use file paths instead of hashes.
|
|
148
|
+
* Pattern: registerServerReference(fn, "hash", "exportName")
|
|
149
|
+
* React's registerServerReference sets $$id = hash + "#" + exportName
|
|
150
|
+
* By replacing the hash with file path, $$id will contain the file path for revalidation matching.
|
|
151
|
+
*
|
|
152
|
+
* Only actions from module-level "use server" files are transformed.
|
|
153
|
+
* Inline actions (defined in RSC components with "use server" inside a function) are NOT in
|
|
154
|
+
* hashToFileMap and keep their hashed IDs. This is intentional for client security:
|
|
155
|
+
* - Module-level "use server" files: shared action modules, file path helps revalidation
|
|
156
|
+
* - Inline actions: one-off actions in RSC, hash ID prevents file path exposure to client
|
|
157
|
+
*
|
|
158
|
+
* @param code - The source code to transform
|
|
159
|
+
* @param sourceId - The source file identifier (for sourcemap)
|
|
160
|
+
* @param hashToFileMap - Mapping from hash to file path (only module-level "use server" files)
|
|
161
|
+
*/
|
|
162
|
+
function transformRegisterServerReference(
|
|
163
|
+
code: string,
|
|
164
|
+
sourceId?: string,
|
|
165
|
+
hashToFileMap?: Map<string, string>
|
|
166
|
+
): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
|
|
167
|
+
if (!hashToFileMap || !code.includes("registerServerReference(")) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Match: registerServerReference(fn, "hash", "exportName")
|
|
172
|
+
// The hash is the second argument, exportName is the third
|
|
173
|
+
const pattern = /registerServerReference\(([^,]+),\s*"([^"]+)",\s*"([^"]+)"\)/g;
|
|
174
|
+
|
|
175
|
+
const s = new MagicString(code);
|
|
176
|
+
let hasChanges = false;
|
|
177
|
+
let match: RegExpExecArray | null;
|
|
178
|
+
|
|
179
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
180
|
+
const [fullMatch, fnArg, hash, exportName] = match;
|
|
181
|
+
const start = match.index;
|
|
182
|
+
const end = start + fullMatch.length;
|
|
183
|
+
|
|
184
|
+
// Look up the file path for this hash
|
|
185
|
+
const filePath = hashToFileMap.get(hash);
|
|
186
|
+
if (filePath) {
|
|
187
|
+
hasChanges = true;
|
|
188
|
+
// WRAP the call to add $id property with file path
|
|
189
|
+
// Keep the original hash for React's action registry (so loadServerAction works)
|
|
190
|
+
// Add $id (single dollar) with file path for revalidation matching
|
|
191
|
+
// Note: We use $id instead of $$id because React's registerServerReference
|
|
192
|
+
// sets $$id as a non-writable property
|
|
193
|
+
const filePathId = `${filePath}#${exportName}`;
|
|
194
|
+
const replacement = `(function(fn) { fn.$id = "${filePathId}"; return fn; })(registerServerReference(${fnArg}, "${hash}", "${exportName}"))`;
|
|
195
|
+
s.overwrite(start, end, replacement);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!hasChanges) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
code: s.toString(),
|
|
205
|
+
map: s.generateMap({ source: sourceId, includeContent: true }),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Vite plugin that exposes action IDs on server reference functions.
|
|
211
|
+
*
|
|
212
|
+
* When React Server Components creates server references via createServerReference(),
|
|
213
|
+
* the action ID (format: "hash#actionName") is passed as the first argument but not
|
|
214
|
+
* exposed on the returned function. This plugin transforms the output to attach
|
|
215
|
+
* the $id property to each server reference function, enabling the router to
|
|
216
|
+
* identify which action was called during revalidation.
|
|
217
|
+
*
|
|
218
|
+
* Server bundles (RSC/SSR) get file paths in $id for filtering (e.g., "src/actions.ts#add").
|
|
219
|
+
* Client bundles keep hashed IDs for security (e.g., "ec387bc704d4#add").
|
|
220
|
+
*
|
|
221
|
+
* Works in:
|
|
222
|
+
* - Build mode: uses renderChunk to transform bundled chunks
|
|
223
|
+
* - Dev mode: uses transform with enforce:"post" to transform after RSC plugin
|
|
224
|
+
*/
|
|
225
|
+
export function exposeActionId(): Plugin {
|
|
226
|
+
let config: ResolvedConfig;
|
|
227
|
+
let isBuild = false;
|
|
228
|
+
let hashToFileMap: Map<string, string> | undefined;
|
|
229
|
+
let rscPluginApi: RscPluginApi | undefined;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
name: "@rangojs/router:expose-action-id",
|
|
233
|
+
// Run after all other plugins (including RSC plugin's transforms)
|
|
234
|
+
enforce: "post",
|
|
235
|
+
|
|
236
|
+
configResolved(resolvedConfig) {
|
|
237
|
+
config = resolvedConfig;
|
|
238
|
+
isBuild = config.command === "build";
|
|
239
|
+
|
|
240
|
+
// Get RSC plugin API - rsc-router requires @vitejs/plugin-rsc
|
|
241
|
+
rscPluginApi = getRscPluginApi(config);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
buildStart() {
|
|
245
|
+
// Verify RSC plugin is present at build start (after all config hooks have run)
|
|
246
|
+
// This allows rsc-router:rsc-integration to dynamically add the RSC plugin
|
|
247
|
+
if (!rscPluginApi) {
|
|
248
|
+
rscPluginApi = getRscPluginApi(config);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!rscPluginApi) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"[rsc-router] Could not find @vitejs/plugin-rsc. " +
|
|
254
|
+
"@rangojs/router requires the Vite RSC plugin.\n" +
|
|
255
|
+
"The RSC plugin should be included automatically. If you disabled it with\n" +
|
|
256
|
+
"rango({ rsc: false }), add rsc() before rango() in your config."
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!isBuild) return;
|
|
261
|
+
|
|
262
|
+
hashToFileMap = new Map();
|
|
263
|
+
const { serverReferenceMetaMap } = rscPluginApi.manager;
|
|
264
|
+
|
|
265
|
+
for (const [absolutePath, meta] of Object.entries(
|
|
266
|
+
serverReferenceMetaMap
|
|
267
|
+
)) {
|
|
268
|
+
// Only include module-level "use server" files
|
|
269
|
+
// Inline actions (defined in RSC components) should keep hashed IDs for client security
|
|
270
|
+
if (!isUseServerModule(absolutePath)) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const relativePath = normalizePath(
|
|
275
|
+
path.relative(config.root, absolutePath)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// The referenceKey in build mode is the hash
|
|
279
|
+
// Map hash -> relative file path
|
|
280
|
+
hashToFileMap.set(meta.referenceKey, relativePath);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
// Dev mode only: transform hook runs after RSC plugin creates server references
|
|
286
|
+
// In dev mode, IDs already contain file paths, not hashes
|
|
287
|
+
transform(code, id) {
|
|
288
|
+
// Skip in build mode - renderChunk handles it
|
|
289
|
+
if (isBuild) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Quick bail-out: only process if code has createServerReference
|
|
294
|
+
if (!code.includes("createServerReference(")) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Skip node_modules
|
|
299
|
+
if (id.includes("/node_modules/")) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Dev mode: no hash-to-file mapping needed (IDs are already file paths)
|
|
304
|
+
return transformServerReferences(code, id);
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// Build mode: renderChunk runs after all transforms and bundling complete
|
|
308
|
+
renderChunk(code, chunk) {
|
|
309
|
+
// Only RSC bundle should get file paths for revalidation matching
|
|
310
|
+
// SSR bundle must NOT use file paths because client components run there
|
|
311
|
+
// and need to match the client bundle during hydration (otherwise: error #418)
|
|
312
|
+
const isRscEnv = this.environment?.name === "rsc";
|
|
313
|
+
|
|
314
|
+
// Only use file path mapping for RSC environment
|
|
315
|
+
const effectiveMap = isRscEnv ? hashToFileMap : undefined;
|
|
316
|
+
|
|
317
|
+
// Transform createServerReference calls (client-side)
|
|
318
|
+
const result = transformServerReferences(
|
|
319
|
+
code,
|
|
320
|
+
chunk.fileName,
|
|
321
|
+
effectiveMap
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// For RSC bundles, also transform registerServerReference calls
|
|
325
|
+
// This replaces hashed IDs with file paths so $id contains the actual path
|
|
326
|
+
if (isRscEnv && hashToFileMap) {
|
|
327
|
+
const codeToTransform = result ? result.code : code;
|
|
328
|
+
const registerResult = transformRegisterServerReference(
|
|
329
|
+
codeToTransform,
|
|
330
|
+
chunk.fileName,
|
|
331
|
+
hashToFileMap
|
|
332
|
+
);
|
|
333
|
+
if (registerResult) {
|
|
334
|
+
return { code: registerResult.code, map: registerResult.map };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (result) {
|
|
339
|
+
return { code: result.code, map: result.map };
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|