@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -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 +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -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-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 +149 -0
- package/src/browser/rsc-router.tsx +310 -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 +443 -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 +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -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 +178 -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.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -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 +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -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 +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -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 +250 -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 +212 -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 +271 -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 +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -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 +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -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 +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
package/src/client.tsx
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Component,
|
|
5
|
+
createElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
Suspense,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { OutletContext, type OutletContextValue } from "./outlet-context.js";
|
|
12
|
+
import {
|
|
13
|
+
type ClientErrorBoundaryFallbackProps,
|
|
14
|
+
type ErrorInfo,
|
|
15
|
+
type LoaderDefinition,
|
|
16
|
+
type LoaderFn,
|
|
17
|
+
type ResolvedSegment,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import {
|
|
20
|
+
RouteContentWrapper,
|
|
21
|
+
LoaderBoundary,
|
|
22
|
+
} from "./route-content-wrapper.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Outlet component - renders child content in layouts
|
|
26
|
+
*
|
|
27
|
+
* If the current segment defines a loading component, the outlet content
|
|
28
|
+
* is wrapped in Suspense with the loading component as fallback.
|
|
29
|
+
* This means during navigation/streaming, React's Suspense will automatically
|
|
30
|
+
* show the loading skeleton until the content is ready.
|
|
31
|
+
*
|
|
32
|
+
* When a name prop is provided (e.g., "@modal"), renders content from
|
|
33
|
+
* the parallel segment with that slot name instead of the default content.
|
|
34
|
+
* This is used for parallel routes and intercepting routes.
|
|
35
|
+
*
|
|
36
|
+
* @param name - Optional slot name for parallel/intercept content (must start with @)
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* function BlogLayout() {
|
|
41
|
+
* return (
|
|
42
|
+
* <div>
|
|
43
|
+
* <h1>Blog</h1>
|
|
44
|
+
* <Outlet />
|
|
45
|
+
* </div>
|
|
46
|
+
* );
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* // With named slot for modal/parallel content:
|
|
50
|
+
* function KanbanLayout() {
|
|
51
|
+
* return (
|
|
52
|
+
* <div>
|
|
53
|
+
* <KanbanBoard />
|
|
54
|
+
* <Outlet name="@modal" />
|
|
55
|
+
* <Outlet />
|
|
56
|
+
* </div>
|
|
57
|
+
* );
|
|
58
|
+
* }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
62
|
+
const context = useContext(OutletContext);
|
|
63
|
+
|
|
64
|
+
// If name provided, render parallel/intercept content for that slot
|
|
65
|
+
if (name) {
|
|
66
|
+
const segment = context?.parallel?.find((seg) => seg.slot === name) ?? null;
|
|
67
|
+
|
|
68
|
+
if (!segment) return null;
|
|
69
|
+
|
|
70
|
+
// Determine the content to render
|
|
71
|
+
let content: ReactNode;
|
|
72
|
+
if (segment.loading || segment.component instanceof Promise) {
|
|
73
|
+
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
74
|
+
content = (
|
|
75
|
+
<RouteContentWrapper
|
|
76
|
+
content={
|
|
77
|
+
segment.component instanceof Promise
|
|
78
|
+
? segment.component
|
|
79
|
+
: Promise.resolve(segment.component)
|
|
80
|
+
}
|
|
81
|
+
fallback={segment.loading}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
content = segment.component ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If segment has a layout, wrap appropriately
|
|
89
|
+
if (segment.layout) {
|
|
90
|
+
// Check if this segment has loaders that need streaming
|
|
91
|
+
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
92
|
+
// When layout renders <Outlet />, it gets the LoaderBoundary which suspends
|
|
93
|
+
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
94
|
+
const loaderAwareContent = (
|
|
95
|
+
<LoaderBoundary
|
|
96
|
+
loaderDataPromise={segment.loaderDataPromise}
|
|
97
|
+
loaderIds={segment.loaderIds}
|
|
98
|
+
fallback={segment.loading}
|
|
99
|
+
outletKey={segment.id + "-loader"}
|
|
100
|
+
outletContent={null}
|
|
101
|
+
segment={segment}
|
|
102
|
+
>
|
|
103
|
+
{content}
|
|
104
|
+
</LoaderBoundary>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
109
|
+
{segment.layout}
|
|
110
|
+
</OutletProvider>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
115
|
+
return (
|
|
116
|
+
<OutletProvider content={content} segment={segment}>
|
|
117
|
+
{segment.layout}
|
|
118
|
+
</OutletProvider>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
123
|
+
// This is common for intercept routes that use useLoader without a custom layout
|
|
124
|
+
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
125
|
+
return (
|
|
126
|
+
<LoaderBoundary
|
|
127
|
+
loaderDataPromise={segment.loaderDataPromise}
|
|
128
|
+
loaderIds={segment.loaderIds}
|
|
129
|
+
fallback={segment.loading}
|
|
130
|
+
outletKey={segment.id + "-loader"}
|
|
131
|
+
outletContent={null}
|
|
132
|
+
segment={segment}
|
|
133
|
+
>
|
|
134
|
+
{content}
|
|
135
|
+
</LoaderBoundary>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return content;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Default: render child content
|
|
143
|
+
const content = context?.content ?? null;
|
|
144
|
+
|
|
145
|
+
// If this segment defines a loading component, wrap outlet content with Suspense
|
|
146
|
+
// The loading component becomes the Suspense fallback, shown during streaming/navigation
|
|
147
|
+
if (context?.loading) {
|
|
148
|
+
return <Suspense fallback={context.loading}>{content}</Suspense>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* ParallelOutlet component - renders content for a named parallel slot
|
|
155
|
+
*
|
|
156
|
+
* If the parallel segment defines a loading component, the content
|
|
157
|
+
* is wrapped in Suspense with the loading component as fallback.
|
|
158
|
+
* This enables streaming and navigation loading states for parallels.
|
|
159
|
+
*
|
|
160
|
+
* @param name - The slot name (must start with @, e.g., "@modal", "@sidebar")
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```tsx
|
|
164
|
+
* function DashboardLayout() {
|
|
165
|
+
* return (
|
|
166
|
+
* <div>
|
|
167
|
+
* <h1>Dashboard</h1>
|
|
168
|
+
* <ParallelOutlet name="@sidebar" />
|
|
169
|
+
* <ParallelOutlet name="@modal" />
|
|
170
|
+
* </div>
|
|
171
|
+
* );
|
|
172
|
+
* }
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
|
|
176
|
+
const context = useContext(OutletContext);
|
|
177
|
+
const segment = useMemo(() => {
|
|
178
|
+
if (!context?.parallel) return null;
|
|
179
|
+
return context.parallel.find((seg) => seg.slot === name) ?? null;
|
|
180
|
+
}, [context, name]);
|
|
181
|
+
|
|
182
|
+
if (!segment) return null;
|
|
183
|
+
|
|
184
|
+
// Determine the content to render
|
|
185
|
+
let content: ReactNode;
|
|
186
|
+
if (segment.loading || segment.component instanceof Promise) {
|
|
187
|
+
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
188
|
+
content = (
|
|
189
|
+
<RouteContentWrapper
|
|
190
|
+
content={
|
|
191
|
+
segment.component instanceof Promise
|
|
192
|
+
? segment.component
|
|
193
|
+
: Promise.resolve(segment.component)
|
|
194
|
+
}
|
|
195
|
+
fallback={segment.loading}
|
|
196
|
+
/>
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
content = segment.component ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// If segment has a layout, wrap appropriately
|
|
203
|
+
if (segment.layout) {
|
|
204
|
+
// Check if this segment has loaders that need streaming
|
|
205
|
+
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
206
|
+
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
207
|
+
const loaderAwareContent = (
|
|
208
|
+
<LoaderBoundary
|
|
209
|
+
loaderDataPromise={segment.loaderDataPromise}
|
|
210
|
+
loaderIds={segment.loaderIds}
|
|
211
|
+
fallback={segment.loading}
|
|
212
|
+
outletKey={segment.id + "-loader"}
|
|
213
|
+
outletContent={null}
|
|
214
|
+
segment={segment}
|
|
215
|
+
>
|
|
216
|
+
{content}
|
|
217
|
+
</LoaderBoundary>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
222
|
+
{segment.layout}
|
|
223
|
+
</OutletProvider>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
228
|
+
return (
|
|
229
|
+
<OutletProvider content={content} segment={segment}>
|
|
230
|
+
{segment.layout}
|
|
231
|
+
</OutletProvider>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
236
|
+
// This is common for intercept routes that use useLoader without a custom layout
|
|
237
|
+
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
238
|
+
return (
|
|
239
|
+
<LoaderBoundary
|
|
240
|
+
loaderDataPromise={segment.loaderDataPromise}
|
|
241
|
+
loaderIds={segment.loaderIds}
|
|
242
|
+
fallback={segment.loading}
|
|
243
|
+
outletKey={segment.id + "-loader"}
|
|
244
|
+
outletContent={null}
|
|
245
|
+
segment={segment}
|
|
246
|
+
>
|
|
247
|
+
{content}
|
|
248
|
+
</LoaderBoundary>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return content;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Provider for outlet content - used internally by renderSegments
|
|
257
|
+
*
|
|
258
|
+
* Stores a reference to parent context so useLoader can walk up the chain
|
|
259
|
+
* to find loader data from parent layouts. If this segment defines a loading
|
|
260
|
+
* component, Outlet will wrap content with Suspense using that as fallback.
|
|
261
|
+
*/
|
|
262
|
+
export function OutletProvider({
|
|
263
|
+
content,
|
|
264
|
+
parallel,
|
|
265
|
+
segment,
|
|
266
|
+
loaderData,
|
|
267
|
+
children,
|
|
268
|
+
}: {
|
|
269
|
+
content: ReactNode;
|
|
270
|
+
parallel?: ResolvedSegment[];
|
|
271
|
+
segment?: ResolvedSegment;
|
|
272
|
+
loaderData?: Record<string, any>;
|
|
273
|
+
children: ReactNode;
|
|
274
|
+
}): ReactNode {
|
|
275
|
+
// Get parent context to enable walking up the chain for loader lookups
|
|
276
|
+
const parentContext = useContext(OutletContext);
|
|
277
|
+
|
|
278
|
+
const value = useMemo(
|
|
279
|
+
() => ({
|
|
280
|
+
content,
|
|
281
|
+
parallel,
|
|
282
|
+
segment,
|
|
283
|
+
loaderData,
|
|
284
|
+
parent: parentContext,
|
|
285
|
+
loading: segment?.loading,
|
|
286
|
+
}),
|
|
287
|
+
[content, parallel, segment, loaderData, parentContext]
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<OutletContext.Provider value={value}>{children}</OutletContext.Provider>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Hook to access outlet content programmatically
|
|
297
|
+
*
|
|
298
|
+
* Alternative to using <Outlet /> component. Useful when you need
|
|
299
|
+
* direct access to the outlet content in your logic.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```tsx
|
|
303
|
+
* function BlogLayout() {
|
|
304
|
+
* const outlet = useOutlet();
|
|
305
|
+
* return <div><h1>Blog</h1>{outlet}</div>;
|
|
306
|
+
* }
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export function useOutlet(): ReactNode {
|
|
310
|
+
const context = useContext(OutletContext);
|
|
311
|
+
return context?.content ?? null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Loader hooks - re-exported from dedicated file
|
|
315
|
+
export {
|
|
316
|
+
useLoader,
|
|
317
|
+
useFetchLoader,
|
|
318
|
+
type LoadFunction,
|
|
319
|
+
type UseLoaderResult,
|
|
320
|
+
type UseFetchLoaderResult,
|
|
321
|
+
type UseLoaderOptions,
|
|
322
|
+
} from "./use-loader.js";
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Hook to access all loader data in the current context
|
|
326
|
+
*
|
|
327
|
+
* Returns a record of all loader data available in the current outlet context
|
|
328
|
+
* and all parent contexts. Useful for debugging or when you need access to
|
|
329
|
+
* multiple loaders.
|
|
330
|
+
*
|
|
331
|
+
* @returns Record of loader name to data, or empty object if no loaders
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* ```tsx
|
|
335
|
+
* "use client";
|
|
336
|
+
* import { useLoaderData } from "rsc-router/client";
|
|
337
|
+
*
|
|
338
|
+
* export function DebugPanel() {
|
|
339
|
+
* const loaderData = useLoaderData();
|
|
340
|
+
* return <pre>{JSON.stringify(loaderData, null, 2)}</pre>;
|
|
341
|
+
* }
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
export function useLoaderData(): Record<string, any> {
|
|
345
|
+
const context = useContext(OutletContext);
|
|
346
|
+
|
|
347
|
+
// Collect all loader data from the context chain
|
|
348
|
+
// Child loaders override parent loaders with the same name
|
|
349
|
+
const result: Record<string, any> = {};
|
|
350
|
+
const stack: OutletContextValue[] = [];
|
|
351
|
+
|
|
352
|
+
// Build stack from current to root
|
|
353
|
+
let current: OutletContextValue | null | undefined = context;
|
|
354
|
+
while (current) {
|
|
355
|
+
stack.push(current);
|
|
356
|
+
current = current.parent;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Apply from root to current (so children override parents)
|
|
360
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
361
|
+
const ctx = stack[i];
|
|
362
|
+
if (ctx.loaderData) {
|
|
363
|
+
Object.assign(result, ctx.loaderData);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Client-safe createLoader factory
|
|
372
|
+
*
|
|
373
|
+
* Creates a loader definition that can be used with useLoader().
|
|
374
|
+
* This is the client-side version that only stores the $$id - the function
|
|
375
|
+
* is ignored since loaders only execute on the server.
|
|
376
|
+
*
|
|
377
|
+
* The $$id is injected by the exposeLoaderId Vite plugin. In most cases,
|
|
378
|
+
* you should import the loader directly from the server file rather than
|
|
379
|
+
* creating a reference manually.
|
|
380
|
+
*
|
|
381
|
+
* @param fn - Loader function (ignored on client, kept for API compatibility)
|
|
382
|
+
* @param _fetchable - Optional fetchable flag (ignored on client)
|
|
383
|
+
* @param __injectedId - $$id injected by Vite plugin
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```tsx
|
|
387
|
+
* "use client";
|
|
388
|
+
* import { useLoader } from "rsc-router/client";
|
|
389
|
+
* import { CartLoader } from "../loaders/cart"; // Import from server file
|
|
390
|
+
*
|
|
391
|
+
* export function CartIcon() {
|
|
392
|
+
* const cart = useLoader(CartLoader);
|
|
393
|
+
* return <span>Cart ({cart?.items.length ?? 0})</span>;
|
|
394
|
+
* }
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
// Overload 1: With function only (not fetchable)
|
|
398
|
+
export function createLoader<T>(
|
|
399
|
+
fn: LoaderFn<T, Record<string, string | undefined>, any>
|
|
400
|
+
): LoaderDefinition<Awaited<T>, Record<string, string | undefined>>;
|
|
401
|
+
|
|
402
|
+
// Overload 2: With function and fetchable flag
|
|
403
|
+
export function createLoader<T>(
|
|
404
|
+
fn: LoaderFn<T, Record<string, string | undefined>, any>,
|
|
405
|
+
fetchable: true
|
|
406
|
+
): LoaderDefinition<Awaited<T>, Record<string, string | undefined>>;
|
|
407
|
+
|
|
408
|
+
// Implementation - function is ignored at runtime on client
|
|
409
|
+
// The $$id is injected by Vite plugin as hidden third parameter
|
|
410
|
+
export function createLoader(
|
|
411
|
+
_fn: LoaderFn<any, Record<string, string | undefined>, any>,
|
|
412
|
+
_fetchable?: true,
|
|
413
|
+
__injectedId?: string
|
|
414
|
+
): LoaderDefinition<any, Record<string, string | undefined>> {
|
|
415
|
+
return {
|
|
416
|
+
__brand: "loader",
|
|
417
|
+
$$id: __injectedId || "",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Props for the ErrorBoundary component
|
|
423
|
+
*/
|
|
424
|
+
export interface ErrorBoundaryProps {
|
|
425
|
+
/** Fallback UI to show when an error is caught */
|
|
426
|
+
fallback:
|
|
427
|
+
| ReactNode
|
|
428
|
+
| ((props: ClientErrorBoundaryFallbackProps) => ReactNode);
|
|
429
|
+
/** Children to render */
|
|
430
|
+
children: ReactNode;
|
|
431
|
+
/** Optional callback when an error is caught */
|
|
432
|
+
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
interface ErrorBoundaryState {
|
|
436
|
+
hasError: boolean;
|
|
437
|
+
error: Error | null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Client-side ErrorBoundary component
|
|
442
|
+
*
|
|
443
|
+
* Catches JavaScript errors in child components during rendering,
|
|
444
|
+
* in lifecycle methods, and in constructors of the whole tree below them.
|
|
445
|
+
* Displays a fallback UI instead of the component tree that crashed.
|
|
446
|
+
*
|
|
447
|
+
* Use this to wrap client components that might throw during hydration
|
|
448
|
+
* or user interaction. For server-side errors (middleware, loaders, handlers),
|
|
449
|
+
* use the errorBoundary() helper in route definitions instead.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* ```tsx
|
|
453
|
+
* "use client";
|
|
454
|
+
* import { ErrorBoundary } from "rsc-router/client";
|
|
455
|
+
*
|
|
456
|
+
* function MyComponent() {
|
|
457
|
+
* return (
|
|
458
|
+
* <ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
459
|
+
* <ComponentThatMightThrow />
|
|
460
|
+
* </ErrorBoundary>
|
|
461
|
+
* );
|
|
462
|
+
* }
|
|
463
|
+
*
|
|
464
|
+
* // Or with a function fallback for more control:
|
|
465
|
+
* function MyComponent() {
|
|
466
|
+
* return (
|
|
467
|
+
* <ErrorBoundary
|
|
468
|
+
* fallback={({ error, reset }) => (
|
|
469
|
+
* <div>
|
|
470
|
+
* <p>Error: {error.message}</p>
|
|
471
|
+
* <button onClick={reset}>Try again</button>
|
|
472
|
+
* </div>
|
|
473
|
+
* )}
|
|
474
|
+
* >
|
|
475
|
+
* <ComponentThatMightThrow />
|
|
476
|
+
* </ErrorBoundary>
|
|
477
|
+
* );
|
|
478
|
+
* }
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
export class ErrorBoundary extends Component<
|
|
482
|
+
ErrorBoundaryProps,
|
|
483
|
+
ErrorBoundaryState
|
|
484
|
+
> {
|
|
485
|
+
constructor(props: ErrorBoundaryProps) {
|
|
486
|
+
super(props);
|
|
487
|
+
this.state = { hasError: false, error: null };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
491
|
+
return { hasError: true, error };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
495
|
+
console.error("[ErrorBoundary] Error caught:", error, errorInfo);
|
|
496
|
+
this.props.onError?.(error, errorInfo);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
reset = (): void => {
|
|
500
|
+
this.setState({ hasError: false, error: null });
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
render(): ReactNode {
|
|
504
|
+
if (this.state.hasError && this.state.error) {
|
|
505
|
+
const { fallback } = this.props;
|
|
506
|
+
|
|
507
|
+
// Create error info for the fallback
|
|
508
|
+
const errorInfo: ErrorInfo = {
|
|
509
|
+
message: this.state.error.message,
|
|
510
|
+
name: this.state.error.name,
|
|
511
|
+
stack: this.state.error.stack,
|
|
512
|
+
cause: this.state.error.cause,
|
|
513
|
+
segmentId: "client",
|
|
514
|
+
segmentType: "route",
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Render fallback - use createElement so hooks work in function fallbacks
|
|
518
|
+
if (typeof fallback === "function") {
|
|
519
|
+
return createElement(fallback, { error: errorInfo, reset: this.reset });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return fallback;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return this.props.children;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ============================================================================
|
|
530
|
+
// Re-exports from browser/react for convenience
|
|
531
|
+
// These are the most commonly used client-side navigation utilities
|
|
532
|
+
// ============================================================================
|
|
533
|
+
|
|
534
|
+
// Navigation hooks
|
|
535
|
+
export {
|
|
536
|
+
useNavigation,
|
|
537
|
+
type NavigationMethods,
|
|
538
|
+
type NavigationValue,
|
|
539
|
+
} from "./browser/react/use-navigation.js";
|
|
540
|
+
|
|
541
|
+
// Action state tracking hook
|
|
542
|
+
export {
|
|
543
|
+
useAction,
|
|
544
|
+
type ServerActionFunction,
|
|
545
|
+
} from "./browser/react/use-action.js";
|
|
546
|
+
|
|
547
|
+
// Segments state hook
|
|
548
|
+
export {
|
|
549
|
+
useSegments,
|
|
550
|
+
type SegmentsState,
|
|
551
|
+
} from "./browser/react/use-segments.js";
|
|
552
|
+
|
|
553
|
+
// Client cache controls hook
|
|
554
|
+
export {
|
|
555
|
+
useClientCache,
|
|
556
|
+
type ClientCacheControls,
|
|
557
|
+
} from "./browser/react/use-client-cache.js";
|
|
558
|
+
|
|
559
|
+
// Provider
|
|
560
|
+
export {
|
|
561
|
+
NavigationProvider,
|
|
562
|
+
type NavigationProviderProps,
|
|
563
|
+
} from "./browser/react/NavigationProvider.js";
|
|
564
|
+
|
|
565
|
+
// Link component
|
|
566
|
+
export {
|
|
567
|
+
Link,
|
|
568
|
+
type LinkProps,
|
|
569
|
+
type PrefetchStrategy,
|
|
570
|
+
type StateOrGetter,
|
|
571
|
+
} from "./browser/react/Link.js";
|
|
572
|
+
|
|
573
|
+
// Link status hook
|
|
574
|
+
export {
|
|
575
|
+
useLinkStatus,
|
|
576
|
+
type LinkStatus,
|
|
577
|
+
} from "./browser/react/use-link-status.js";
|
|
578
|
+
|
|
579
|
+
// Scroll restoration
|
|
580
|
+
export {
|
|
581
|
+
ScrollRestoration,
|
|
582
|
+
useScrollRestoration,
|
|
583
|
+
type ScrollRestorationProps,
|
|
584
|
+
} from "./browser/react/ScrollRestoration.js";
|
|
585
|
+
|
|
586
|
+
// Handle API - for accumulating data across route segments
|
|
587
|
+
export { createHandle, isHandle, type Handle } from "./handle.js";
|
|
588
|
+
|
|
589
|
+
// Handle data hook
|
|
590
|
+
export { useHandle } from "./browser/react/use-handle.js";
|
|
591
|
+
|
|
592
|
+
// Built-in handles
|
|
593
|
+
export { Meta } from "./handles/meta.js";
|
|
594
|
+
export { MetaTags } from "./handles/MetaTags.js";
|
|
595
|
+
export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
|
|
596
|
+
|
|
597
|
+
// Location state - type-safe navigation state
|
|
598
|
+
export {
|
|
599
|
+
createLocationState,
|
|
600
|
+
useLocationState,
|
|
601
|
+
type LocationStateDefinition,
|
|
602
|
+
type LocationStateEntry,
|
|
603
|
+
} from "./browser/react/location-state.js";
|
|
604
|
+
|
|
605
|
+
// Type-safe href for client-side path validation
|
|
606
|
+
export { href, type ValidPaths, type PatternToPath } from "./href-client.js";
|
|
607
|
+
|
|
608
|
+
// Loader definition type - for typing loader props in client components
|
|
609
|
+
export type { LoaderDefinition } from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode, ReactElement } from "react";
|
|
4
|
+
import { MetaTags } from "../handles/MetaTags.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default document component that provides a basic HTML structure.
|
|
8
|
+
* Used when no custom document is provided to createRSCRouter.
|
|
9
|
+
* Includes MetaTags for automatic charset, viewport, and route meta support.
|
|
10
|
+
*/
|
|
11
|
+
export function DefaultDocument({ children }: { children: ReactNode }): ReactElement {
|
|
12
|
+
return (
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<MetaTags />
|
|
16
|
+
</head>
|
|
17
|
+
<body>{children}</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ErrorBoundaryFallbackProps } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default error boundary fallback component
|
|
6
|
+
*
|
|
7
|
+
* This is rendered when an error occurs and no custom error boundary
|
|
8
|
+
* is defined in the route tree. Shows a simple "Internal Server Error"
|
|
9
|
+
* message with the error details in development.
|
|
10
|
+
*/
|
|
11
|
+
export function DefaultErrorFallback({
|
|
12
|
+
error,
|
|
13
|
+
}: ErrorBoundaryFallbackProps): ReactNode {
|
|
14
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
style={{
|
|
19
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
20
|
+
padding: "2rem",
|
|
21
|
+
maxWidth: "600px",
|
|
22
|
+
margin: "2rem auto",
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<h1
|
|
26
|
+
style={{
|
|
27
|
+
color: "#dc2626",
|
|
28
|
+
fontSize: "1.5rem",
|
|
29
|
+
marginBottom: "1rem",
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
Internal Server Error
|
|
33
|
+
</h1>
|
|
34
|
+
<p
|
|
35
|
+
style={{
|
|
36
|
+
color: "#374151",
|
|
37
|
+
marginBottom: "1rem",
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
An unexpected error occurred while processing your request.
|
|
41
|
+
</p>
|
|
42
|
+
{isDev && (
|
|
43
|
+
<div
|
|
44
|
+
style={{
|
|
45
|
+
background: "#fef2f2",
|
|
46
|
+
border: "1px solid #fecaca",
|
|
47
|
+
borderRadius: "0.5rem",
|
|
48
|
+
padding: "1rem",
|
|
49
|
+
marginBottom: "1rem",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<p
|
|
53
|
+
style={{
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
color: "#991b1b",
|
|
56
|
+
marginBottom: "0.5rem",
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{error.name}: {error.message}
|
|
60
|
+
</p>
|
|
61
|
+
{error.stack && (
|
|
62
|
+
<pre
|
|
63
|
+
style={{
|
|
64
|
+
fontSize: "0.75rem",
|
|
65
|
+
color: "#6b7280",
|
|
66
|
+
overflow: "auto",
|
|
67
|
+
whiteSpace: "pre-wrap",
|
|
68
|
+
wordBreak: "break-word",
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{error.stack}
|
|
72
|
+
</pre>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<a
|
|
77
|
+
href="/"
|
|
78
|
+
style={{
|
|
79
|
+
display: "inline-block",
|
|
80
|
+
color: "#2563eb",
|
|
81
|
+
textDecoration: "underline",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
Go to homepage
|
|
85
|
+
</a>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|