@ivogt/rsc-router 0.0.0-experimental.1

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 (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -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,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ /**
4
+ * Mock function to test createLoader detection patterns
5
+ */
6
+ function hasCreateLoaderImport(code: string): boolean {
7
+ const pattern = /import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']rsc-router(?:\/server)?["']/;
8
+ return pattern.test(code);
9
+ }
10
+
11
+ /**
12
+ * Extract loader exports from code
13
+ */
14
+ function extractLoaderExports(code: string): string[] {
15
+ const pattern = /export\s+const\s+(\w+)\s*=\s*createLoader\s*\(/g;
16
+ const exports: string[] = [];
17
+ let match: RegExpExecArray | null;
18
+ while ((match = pattern.exec(code)) !== null) {
19
+ exports.push(match[1]);
20
+ }
21
+ return exports;
22
+ }
23
+
24
+ describe("exposeLoaderId plugin", () => {
25
+ describe("hasCreateLoaderImport", () => {
26
+ it("should detect direct import from rsc-router", () => {
27
+ const code = `import { createLoader } from "rsc-router";`;
28
+ expect(hasCreateLoaderImport(code)).toBe(true);
29
+ });
30
+
31
+ it("should detect import from rsc-router/server", () => {
32
+ const code = `import { createLoader } from "rsc-router/server";`;
33
+ expect(hasCreateLoaderImport(code)).toBe(true);
34
+ });
35
+
36
+ it("should detect createLoader with other imports", () => {
37
+ const code = `import { map, createLoader, route } from "rsc-router";`;
38
+ expect(hasCreateLoaderImport(code)).toBe(true);
39
+ });
40
+
41
+ it("should NOT detect aliased import", () => {
42
+ const code = `import { createLoader as cl } from "rsc-router";`;
43
+ // Our simple pattern doesn't support aliasing - this is intentional
44
+ expect(hasCreateLoaderImport(code)).toBe(true); // Still matches the word
45
+ });
46
+
47
+ it("should NOT detect import from other packages", () => {
48
+ const code = `import { createLoader } from "other-package";`;
49
+ expect(hasCreateLoaderImport(code)).toBe(false);
50
+ });
51
+
52
+ it("should NOT detect default import", () => {
53
+ const code = `import createLoader from "rsc-router";`;
54
+ expect(hasCreateLoaderImport(code)).toBe(false);
55
+ });
56
+
57
+ it("should NOT detect namespace import", () => {
58
+ const code = `import * as router from "rsc-router";`;
59
+ expect(hasCreateLoaderImport(code)).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("extractLoaderExports", () => {
64
+ it("should extract single loader export", () => {
65
+ const code = `export const MyLoader = createLoader("test", fn);`;
66
+ expect(extractLoaderExports(code)).toEqual(["MyLoader"]);
67
+ });
68
+
69
+ it("should extract multiple loader exports", () => {
70
+ const code = `
71
+ export const LoaderA = createLoader("a", fnA);
72
+ export const LoaderB = createLoader("b", fnB);
73
+ export const LoaderC = createLoader("c", fnC, true);
74
+ `;
75
+ expect(extractLoaderExports(code)).toEqual(["LoaderA", "LoaderB", "LoaderC"]);
76
+ });
77
+
78
+ it("should NOT extract non-exported loaders", () => {
79
+ const code = `const PrivateLoader = createLoader("private", fn);`;
80
+ expect(extractLoaderExports(code)).toEqual([]);
81
+ });
82
+
83
+ it("should NOT extract default exports", () => {
84
+ const code = `export default createLoader("default", fn);`;
85
+ expect(extractLoaderExports(code)).toEqual([]);
86
+ });
87
+
88
+ it("should handle multiline exports", () => {
89
+ const code = `
90
+ export const MyLoader = createLoader(
91
+ "my-loader",
92
+ async (ctx) => {
93
+ return { data: "test" };
94
+ },
95
+ true
96
+ );
97
+ `;
98
+ expect(extractLoaderExports(code)).toEqual(["MyLoader"]);
99
+ });
100
+ });
101
+
102
+ describe("$$id generation", () => {
103
+ it("should generate correct $$id format", () => {
104
+ const filePath = "src/loaders/test-loader.ts";
105
+ const exportName = "TestLoader";
106
+ const id = `${filePath}#${exportName}`;
107
+ expect(id).toBe("src/loaders/test-loader.ts#TestLoader");
108
+ });
109
+
110
+ it("should handle nested paths", () => {
111
+ const filePath = "src/handlers/shop/loaders/cart.ts";
112
+ const exportName = "CartLoader";
113
+ const id = `${filePath}#${exportName}`;
114
+ expect(id).toBe("src/handlers/shop/loaders/cart.ts#CartLoader");
115
+ });
116
+ });
117
+ });