@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,277 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, useState, type ReactNode } from "react";
|
|
4
|
+
import type { ClientErrorBoundaryFallbackProps } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if an error is a network-related error
|
|
8
|
+
*/
|
|
9
|
+
function isNetworkError(error: Error): boolean {
|
|
10
|
+
return error.name === "NetworkError";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Network error fallback UI with retry functionality
|
|
15
|
+
* Shows a connection-specific message and allows retrying via page refresh
|
|
16
|
+
*/
|
|
17
|
+
function NetworkErrorFallback({
|
|
18
|
+
error,
|
|
19
|
+
reset,
|
|
20
|
+
}: ClientErrorBoundaryFallbackProps): ReactNode {
|
|
21
|
+
const [isRetrying, setIsRetrying] = useState(false);
|
|
22
|
+
|
|
23
|
+
const handleRetry = (): void => {
|
|
24
|
+
setIsRetrying(true);
|
|
25
|
+
// Refresh the page to retry the request
|
|
26
|
+
window.location.reload();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
33
|
+
padding: "2rem",
|
|
34
|
+
maxWidth: "600px",
|
|
35
|
+
margin: "2rem auto",
|
|
36
|
+
textAlign: "center",
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
style={{
|
|
41
|
+
fontSize: "3rem",
|
|
42
|
+
marginBottom: "1rem",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{/* Simple cloud with x icon using CSS */}
|
|
46
|
+
<span style={{ color: "#9ca3af" }}>☁</span>
|
|
47
|
+
</div>
|
|
48
|
+
<h1
|
|
49
|
+
style={{
|
|
50
|
+
color: "#374151",
|
|
51
|
+
fontSize: "1.5rem",
|
|
52
|
+
marginBottom: "0.5rem",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
Connection Error
|
|
56
|
+
</h1>
|
|
57
|
+
<p
|
|
58
|
+
style={{
|
|
59
|
+
color: "#6b7280",
|
|
60
|
+
marginBottom: "1.5rem",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{error.message || "Unable to connect to the server. Please check your internet connection."}
|
|
64
|
+
</p>
|
|
65
|
+
<div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={handleRetry}
|
|
69
|
+
disabled={isRetrying}
|
|
70
|
+
style={{
|
|
71
|
+
padding: "0.75rem 1.5rem",
|
|
72
|
+
backgroundColor: isRetrying ? "#9ca3af" : "#2563eb",
|
|
73
|
+
color: "white",
|
|
74
|
+
border: "none",
|
|
75
|
+
borderRadius: "0.375rem",
|
|
76
|
+
cursor: isRetrying ? "not-allowed" : "pointer",
|
|
77
|
+
fontSize: "1rem",
|
|
78
|
+
fontWeight: 500,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{isRetrying ? "Retrying..." : "Retry"}
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => window.history.back()}
|
|
86
|
+
style={{
|
|
87
|
+
padding: "0.75rem 1.5rem",
|
|
88
|
+
backgroundColor: "transparent",
|
|
89
|
+
color: "#6b7280",
|
|
90
|
+
border: "1px solid #d1d5db",
|
|
91
|
+
borderRadius: "0.375rem",
|
|
92
|
+
cursor: "pointer",
|
|
93
|
+
fontSize: "1rem",
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
Go Back
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Default fallback UI for root error boundary
|
|
105
|
+
* This is shown when an unhandled error bubbles up to the root
|
|
106
|
+
*/
|
|
107
|
+
function RootErrorFallback({ error, reset }: ClientErrorBoundaryFallbackProps): ReactNode {
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
style={{
|
|
111
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
112
|
+
padding: "2rem",
|
|
113
|
+
maxWidth: "600px",
|
|
114
|
+
margin: "2rem auto",
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<h1
|
|
118
|
+
style={{
|
|
119
|
+
color: "#dc2626",
|
|
120
|
+
fontSize: "1.5rem",
|
|
121
|
+
marginBottom: "1rem",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
Internal Server Error
|
|
125
|
+
</h1>
|
|
126
|
+
<p
|
|
127
|
+
style={{
|
|
128
|
+
color: "#374151",
|
|
129
|
+
marginBottom: "1rem",
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
An unexpected error occurred while processing your request.
|
|
133
|
+
</p>
|
|
134
|
+
<div
|
|
135
|
+
style={{
|
|
136
|
+
background: "#fef2f2",
|
|
137
|
+
border: "1px solid #fecaca",
|
|
138
|
+
borderRadius: "0.5rem",
|
|
139
|
+
padding: "1rem",
|
|
140
|
+
marginBottom: "1rem",
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<p
|
|
144
|
+
style={{
|
|
145
|
+
fontWeight: 600,
|
|
146
|
+
color: "#991b1b",
|
|
147
|
+
marginBottom: "0.5rem",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{error.name}: {error.message}
|
|
151
|
+
</p>
|
|
152
|
+
{error.stack && (
|
|
153
|
+
<pre
|
|
154
|
+
style={{
|
|
155
|
+
fontSize: "0.75rem",
|
|
156
|
+
color: "#6b7280",
|
|
157
|
+
overflow: "auto",
|
|
158
|
+
whiteSpace: "pre-wrap",
|
|
159
|
+
wordBreak: "break-word",
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{error.stack}
|
|
163
|
+
</pre>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<div style={{ display: "flex", gap: "1rem" }}>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={reset}
|
|
170
|
+
style={{
|
|
171
|
+
padding: "0.5rem 1rem",
|
|
172
|
+
backgroundColor: "#2563eb",
|
|
173
|
+
color: "white",
|
|
174
|
+
border: "none",
|
|
175
|
+
borderRadius: "0.25rem",
|
|
176
|
+
cursor: "pointer",
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
Try Again
|
|
180
|
+
</button>
|
|
181
|
+
<a
|
|
182
|
+
href="/"
|
|
183
|
+
style={{
|
|
184
|
+
display: "inline-block",
|
|
185
|
+
padding: "0.5rem 1rem",
|
|
186
|
+
color: "#2563eb",
|
|
187
|
+
textDecoration: "underline",
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
Go to homepage
|
|
191
|
+
</a>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface RootErrorBoundaryState {
|
|
198
|
+
hasError: boolean;
|
|
199
|
+
error: Error | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Root error boundary component
|
|
204
|
+
*
|
|
205
|
+
* Wraps the entire segment tree to catch any unhandled errors that bubble up.
|
|
206
|
+
* This prevents the entire app from crashing with a white screen.
|
|
207
|
+
*
|
|
208
|
+
* This is a client component with an inline fallback to avoid the
|
|
209
|
+
* "Functions cannot be passed to Client Components" RSC error.
|
|
210
|
+
*/
|
|
211
|
+
export class RootErrorBoundary extends Component<
|
|
212
|
+
{ children: ReactNode },
|
|
213
|
+
RootErrorBoundaryState
|
|
214
|
+
> {
|
|
215
|
+
constructor(props: { children: ReactNode }) {
|
|
216
|
+
super(props);
|
|
217
|
+
this.state = { hasError: false, error: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
static getDerivedStateFromError(error: Error): RootErrorBoundaryState {
|
|
221
|
+
return { hasError: true, error };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
componentDidMount(): void {
|
|
225
|
+
// Listen for popstate (back/forward navigation) to reset error state
|
|
226
|
+
window.addEventListener("popstate", this.handlePopState);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
componentWillUnmount(): void {
|
|
230
|
+
window.removeEventListener("popstate", this.handlePopState);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
234
|
+
console.error("[RootErrorBoundary] Unhandled error caught:", error, errorInfo);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
componentDidUpdate(prevProps: { children: ReactNode }): void {
|
|
238
|
+
// Reset error state when children change (e.g., navigation)
|
|
239
|
+
// This allows the app to recover after navigation away from an errored route
|
|
240
|
+
if (this.state.hasError && prevProps.children !== this.props.children) {
|
|
241
|
+
this.setState({ hasError: false, error: null });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
handlePopState = (): void => {
|
|
246
|
+
// Reset error state on back/forward navigation
|
|
247
|
+
if (this.state.hasError) {
|
|
248
|
+
this.setState({ hasError: false, error: null });
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
reset = (): void => {
|
|
253
|
+
this.setState({ hasError: false, error: null });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
render(): ReactNode {
|
|
257
|
+
if (this.state.hasError && this.state.error) {
|
|
258
|
+
const errorInfo = {
|
|
259
|
+
message: this.state.error.message,
|
|
260
|
+
name: this.state.error.name,
|
|
261
|
+
stack: this.state.error.stack,
|
|
262
|
+
cause: this.state.error.cause,
|
|
263
|
+
segmentId: "root",
|
|
264
|
+
segmentType: "route" as const,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Use specialized fallback for network errors
|
|
268
|
+
if (isNetworkError(this.state.error)) {
|
|
269
|
+
return <NetworkErrorFallback error={errorInfo} reset={this.reset} />;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return <RootErrorFallback error={errorInfo} reset={this.reset} />;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return this.props.children;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Suspense, use, useId } from "react";
|
|
4
|
+
import { invariant } from "./errors";
|
|
5
|
+
import { OutletProvider } from "./client.js";
|
|
6
|
+
import type { ResolvedSegment } from "./types.js";
|
|
7
|
+
import { isLoaderDataResult } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stable async wrapper component for route content
|
|
11
|
+
* Using a module-level component ensures React sees the same component reference
|
|
12
|
+
* across renders, preventing unnecessary remounts during actions.
|
|
13
|
+
*
|
|
14
|
+
* When content is a pending promise, React suspends and shows the nearest
|
|
15
|
+
* Suspense fallback. When content is already resolved, it renders immediately
|
|
16
|
+
* without suspension.
|
|
17
|
+
*
|
|
18
|
+
* @param segmentId - Stable ID from segment, used for consistent keys across renders
|
|
19
|
+
*/
|
|
20
|
+
export function RouteContentWrapper({
|
|
21
|
+
content,
|
|
22
|
+
fallback,
|
|
23
|
+
segmentId,
|
|
24
|
+
}: {
|
|
25
|
+
content: Promise<ReactNode>;
|
|
26
|
+
fallback?: ReactNode;
|
|
27
|
+
segmentId?: string;
|
|
28
|
+
}): ReactNode {
|
|
29
|
+
// Use server-provided segmentId for stable keys, fall back to useId for backwards compat
|
|
30
|
+
const generatedId = useId();
|
|
31
|
+
const id = segmentId || generatedId;
|
|
32
|
+
if (!content) {
|
|
33
|
+
// Already resolved
|
|
34
|
+
return content as ReactNode;
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<Suspense fallback={fallback ?? null} key={"route-content-suspense-" + id}>
|
|
38
|
+
<Suspender content={content} key={id} />
|
|
39
|
+
</Suspense>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function RouteContentWrapperCallback<T>({
|
|
44
|
+
resolve,
|
|
45
|
+
fallback,
|
|
46
|
+
children,
|
|
47
|
+
}: {
|
|
48
|
+
resolve: Promise<T> | T;
|
|
49
|
+
fallback?: ReactNode;
|
|
50
|
+
children: (data: T) => ReactNode;
|
|
51
|
+
}): ReactNode {
|
|
52
|
+
const id = useId();
|
|
53
|
+
invariant(children, "RouteContentWrapperCallback requires children");
|
|
54
|
+
invariant(
|
|
55
|
+
typeof children === "function",
|
|
56
|
+
"RouteContentWrapperCallback requires children to be a function"
|
|
57
|
+
);
|
|
58
|
+
invariant(
|
|
59
|
+
resolve !== undefined,
|
|
60
|
+
"RouteContentWrapperCallback requires resolve"
|
|
61
|
+
);
|
|
62
|
+
return (
|
|
63
|
+
<Suspense
|
|
64
|
+
fallback={fallback ?? null}
|
|
65
|
+
key={"route-content-suspense-callback-" + id}
|
|
66
|
+
>
|
|
67
|
+
<SuspenderCallback resolve={resolve} key={id}>
|
|
68
|
+
{children}
|
|
69
|
+
</SuspenderCallback>
|
|
70
|
+
</Suspense>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const Suspender = ({
|
|
75
|
+
content,
|
|
76
|
+
}: {
|
|
77
|
+
content: Promise<ReactNode> | ReactNode;
|
|
78
|
+
}): ReactNode => {
|
|
79
|
+
invariant(content instanceof Promise, "Suspender expects a Promise content");
|
|
80
|
+
|
|
81
|
+
return use(content);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const SuspenderCallback = <T,>({
|
|
85
|
+
resolve,
|
|
86
|
+
children,
|
|
87
|
+
}: {
|
|
88
|
+
resolve: Promise<T> | T;
|
|
89
|
+
children: (data: T) => ReactNode;
|
|
90
|
+
}): ReactNode => {
|
|
91
|
+
return resolve instanceof Promise
|
|
92
|
+
? children(use(resolve))
|
|
93
|
+
: children(resolve);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* LoaderBoundary - Client component that resolves loader promises and renders OutletProvider
|
|
98
|
+
*
|
|
99
|
+
* This component enables streaming with loaders by:
|
|
100
|
+
* 1. Receiving loader promises (serializable across RSC boundary)
|
|
101
|
+
* 2. Using React's use() to resolve them (triggers Suspense)
|
|
102
|
+
* 3. Rendering OutletProvider with resolved data
|
|
103
|
+
*
|
|
104
|
+
* The callback logic lives inside this client component, avoiding the
|
|
105
|
+
* "Functions are not valid as a child of Client Components" error.
|
|
106
|
+
*/
|
|
107
|
+
export interface LoaderBoundaryProps {
|
|
108
|
+
loaderDataPromise: Promise<any[]> | any[];
|
|
109
|
+
loaderIds: string[];
|
|
110
|
+
fallback?: ReactNode;
|
|
111
|
+
outletKey: string;
|
|
112
|
+
outletContent: ReactNode;
|
|
113
|
+
segment: ResolvedSegment;
|
|
114
|
+
parallel?: ResolvedSegment[];
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function LoaderBoundary({
|
|
119
|
+
loaderDataPromise,
|
|
120
|
+
loaderIds,
|
|
121
|
+
fallback,
|
|
122
|
+
outletKey,
|
|
123
|
+
outletContent,
|
|
124
|
+
segment,
|
|
125
|
+
parallel,
|
|
126
|
+
children,
|
|
127
|
+
}: LoaderBoundaryProps): ReactNode {
|
|
128
|
+
const id = useId();
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Suspense fallback={fallback ?? null} key={`loader-boundary-${id}`}>
|
|
132
|
+
<LoaderResolver
|
|
133
|
+
loaderDataPromise={loaderDataPromise}
|
|
134
|
+
loaderIds={loaderIds}
|
|
135
|
+
outletKey={outletKey}
|
|
136
|
+
outletContent={outletContent}
|
|
137
|
+
segment={segment}
|
|
138
|
+
parallel={parallel}
|
|
139
|
+
>
|
|
140
|
+
{children}
|
|
141
|
+
</LoaderResolver>
|
|
142
|
+
</Suspense>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Internal component that resolves loader promises and renders OutletProvider
|
|
148
|
+
*/
|
|
149
|
+
function LoaderResolver({
|
|
150
|
+
loaderDataPromise,
|
|
151
|
+
loaderIds,
|
|
152
|
+
outletKey,
|
|
153
|
+
outletContent,
|
|
154
|
+
segment,
|
|
155
|
+
parallel,
|
|
156
|
+
children,
|
|
157
|
+
}: Omit<LoaderBoundaryProps, "fallback">): ReactNode {
|
|
158
|
+
// Resolve loader promises using React's use()
|
|
159
|
+
const resolvedData =
|
|
160
|
+
loaderDataPromise instanceof Promise
|
|
161
|
+
? use(loaderDataPromise)
|
|
162
|
+
: loaderDataPromise;
|
|
163
|
+
|
|
164
|
+
// Build loaderData record from resolved values
|
|
165
|
+
const loaderData: Record<string, any> = {};
|
|
166
|
+
let loaderErrorFallback: ReactNode = null;
|
|
167
|
+
|
|
168
|
+
loaderIds.forEach((id, i) => {
|
|
169
|
+
const result = resolvedData[i];
|
|
170
|
+
|
|
171
|
+
if (isLoaderDataResult(result)) {
|
|
172
|
+
if (result.ok) {
|
|
173
|
+
loaderData[id] = result.data;
|
|
174
|
+
} else {
|
|
175
|
+
if (result.fallback) {
|
|
176
|
+
loaderErrorFallback = result.fallback;
|
|
177
|
+
} else {
|
|
178
|
+
throw new Error(result.error.message);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Legacy format - direct data
|
|
183
|
+
loaderData[id] = result;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<OutletProvider
|
|
189
|
+
key={outletKey}
|
|
190
|
+
content={outletContent}
|
|
191
|
+
segment={segment}
|
|
192
|
+
parallel={parallel}
|
|
193
|
+
loaderData={Object.keys(loaderData).length > 0 ? loaderData : undefined}
|
|
194
|
+
>
|
|
195
|
+
{loaderErrorFallback ?? children}
|
|
196
|
+
</OutletProvider>
|
|
197
|
+
);
|
|
198
|
+
}
|