@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
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { createElement, type ReactNode, type ComponentType } from "react";
|
|
2
|
+
import { OutletProvider } from "./client.js";
|
|
3
|
+
import type {
|
|
4
|
+
ResolvedSegment,
|
|
5
|
+
LoaderDataResult,
|
|
6
|
+
RootLayoutProps,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { isLoaderDataResult } from "./types.js";
|
|
9
|
+
import { invariant } from "./errors.js";
|
|
10
|
+
import {
|
|
11
|
+
RouteContentWrapper,
|
|
12
|
+
LoaderBoundary,
|
|
13
|
+
} from "./route-content-wrapper.js";
|
|
14
|
+
import { RootErrorBoundary } from "./root-error-boundary.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
|
|
18
|
+
*/
|
|
19
|
+
function resolveLoaderData(
|
|
20
|
+
resolvedData: any[],
|
|
21
|
+
loaderIds: string[]
|
|
22
|
+
): { loaderData: Record<string, any>; errorFallback: ReactNode } {
|
|
23
|
+
const loaderData: Record<string, any> = {};
|
|
24
|
+
let errorFallback: ReactNode = null;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < loaderIds.length; i++) {
|
|
27
|
+
const id = loaderIds[i];
|
|
28
|
+
const result = resolvedData[i];
|
|
29
|
+
|
|
30
|
+
if (!isLoaderDataResult(result)) {
|
|
31
|
+
// Legacy format - direct data
|
|
32
|
+
loaderData[id] = result;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (result.ok) {
|
|
37
|
+
loaderData[id] = result.data;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Error case
|
|
42
|
+
if (result.fallback) {
|
|
43
|
+
errorFallback = result.fallback;
|
|
44
|
+
} else {
|
|
45
|
+
throw new Error(result.error.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { loaderData, errorFallback };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Options for renderSegments
|
|
54
|
+
*/
|
|
55
|
+
export interface RenderSegmentsOptions {
|
|
56
|
+
/**
|
|
57
|
+
* If true, this render is for a server action response.
|
|
58
|
+
* In browser during actions, we await component promises to prevent
|
|
59
|
+
* UI flickering/suspense during optimistic updates.
|
|
60
|
+
*/
|
|
61
|
+
isAction?: boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* If true, force awaiting all loaders instead of streaming with Suspense.
|
|
65
|
+
* Used for popstate (back/forward) navigation where we want instant rendering
|
|
66
|
+
* from cache without showing loading skeletons.
|
|
67
|
+
*/
|
|
68
|
+
forceAwait?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Intercept segments to inject into the tree.
|
|
72
|
+
* These are parallel segments from intercept routes that need to be
|
|
73
|
+
* associated with their parent layout's named outlet.
|
|
74
|
+
*
|
|
75
|
+
* Passed separately for explicit handling - makes the flow clearer
|
|
76
|
+
* and easier to debug than relying on ID pattern matching.
|
|
77
|
+
*/
|
|
78
|
+
interceptSegments?: ResolvedSegment[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Root layout component that wraps the entire application.
|
|
82
|
+
* When provided, wraps both route content and the error boundary,
|
|
83
|
+
* preventing the app shell from unmounting during errors (avoids FOUC).
|
|
84
|
+
*/
|
|
85
|
+
rootLayout?: ComponentType<RootLayoutProps>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Render segments into a React tree with proper layout nesting
|
|
90
|
+
*
|
|
91
|
+
* Layouts nest using OutletProvider, while route + parallel + error + notFound segments
|
|
92
|
+
* render as siblings in a Fragment.
|
|
93
|
+
*
|
|
94
|
+
* Error segments are treated like route segments - they render their fallback
|
|
95
|
+
* component in place of the failed segment. When an error occurs in a handler,
|
|
96
|
+
* loader, or middleware, the router creates an error segment with the nearest
|
|
97
|
+
* error boundary's fallback component.
|
|
98
|
+
*
|
|
99
|
+
* NotFound segments are similar to error segments but are triggered by
|
|
100
|
+
* DataNotFoundError (thrown via notFound()). They render the nearest
|
|
101
|
+
* notFoundBoundary's fallback component.
|
|
102
|
+
*
|
|
103
|
+
* @param segments - Array of resolved segments to render
|
|
104
|
+
* @returns ReactNode representing the component tree
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* const segments = [
|
|
109
|
+
* { id: 'L0.0', type: 'layout', component: <RootLayout /> },
|
|
110
|
+
* { id: 'L1.0', type: 'layout', component: <BlogLayout /> },
|
|
111
|
+
* { id: 'R2.0', type: 'route', component: <BlogPost /> },
|
|
112
|
+
* { id: 'P3.0', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
|
|
113
|
+
* ];
|
|
114
|
+
*
|
|
115
|
+
* const tree = renderSegments(segments);
|
|
116
|
+
* // Results in:
|
|
117
|
+
* // <OutletProvider><RootLayout>
|
|
118
|
+
* // <OutletProvider><BlogLayout>
|
|
119
|
+
* // <><BlogPost /><Sidebar /></>
|
|
120
|
+
* // </BlogLayout></OutletProvider>
|
|
121
|
+
* // </RootLayout></OutletProvider>
|
|
122
|
+
*
|
|
123
|
+
* // For server actions, pass isAction to await components:
|
|
124
|
+
* const tree = renderSegments(segments, { isAction: true });
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export async function renderSegments(
|
|
128
|
+
segments: ResolvedSegment[],
|
|
129
|
+
options?: RenderSegmentsOptions
|
|
130
|
+
): Promise<ReactNode> {
|
|
131
|
+
const {
|
|
132
|
+
isAction,
|
|
133
|
+
interceptSegments,
|
|
134
|
+
forceAwait,
|
|
135
|
+
rootLayout: RootLayout,
|
|
136
|
+
} = options || {};
|
|
137
|
+
|
|
138
|
+
// Separate segments by type, passing intercept segments for explicit injection
|
|
139
|
+
const tree = segmentTreeWalk(segments, interceptSegments);
|
|
140
|
+
// Render content segments as siblings
|
|
141
|
+
let content: ReactNode = null;
|
|
142
|
+
for (const node of tree) {
|
|
143
|
+
invariant(
|
|
144
|
+
node.segment.type === "layout" ||
|
|
145
|
+
node.segment.type === "route" ||
|
|
146
|
+
node.segment.type === "error" ||
|
|
147
|
+
node.segment.type === "notFound",
|
|
148
|
+
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`
|
|
149
|
+
);
|
|
150
|
+
const { component, id, params, loading } = node.segment;
|
|
151
|
+
|
|
152
|
+
// Only include params in key for segments that belong to the route
|
|
153
|
+
// - Routes: always include params (they render param-specific content)
|
|
154
|
+
// - Error/notFound segments: always include params (they replace failed route content)
|
|
155
|
+
// - Route's layouts (orphans): include params (children of parameterized route)
|
|
156
|
+
// - Parent chain layouts: exclude params (shared across routes, param-agnostic)
|
|
157
|
+
// This prevents unnecessary unmounting when params change
|
|
158
|
+
const includeParams =
|
|
159
|
+
node.segment.type === "route" ||
|
|
160
|
+
node.segment.type === "error" ||
|
|
161
|
+
node.segment.type === "notFound" ||
|
|
162
|
+
(node.segment.type === "layout" && node.segment.belongsToRoute);
|
|
163
|
+
|
|
164
|
+
const paramStr =
|
|
165
|
+
includeParams && params && Object.keys(params).length > 0
|
|
166
|
+
? Object.entries(params)
|
|
167
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
168
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
169
|
+
.join(",")
|
|
170
|
+
: "";
|
|
171
|
+
const key = `${paramStr ? `${id}-${paramStr}` : id}`;
|
|
172
|
+
|
|
173
|
+
// Get loader entries for this node
|
|
174
|
+
const loaderEntries = node.loaders.filter(
|
|
175
|
+
(loader) => loader.loaderId && loader.loaderData !== undefined
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Determine the component content (with or without Suspense wrapper)
|
|
179
|
+
// Wrap when loading skeleton defined OR component is Promise (needs Suspense)
|
|
180
|
+
// During actions, await component Promise to prevent Suspense from triggering
|
|
181
|
+
// This keeps existing content visible instead of showing loading skeleton
|
|
182
|
+
let resolvedComponent = component;
|
|
183
|
+
if (isAction && component instanceof Promise) {
|
|
184
|
+
resolvedComponent = await component;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let nodeContent: ReactNode =
|
|
188
|
+
loading || loading === null || resolvedComponent instanceof Promise
|
|
189
|
+
? createElement(RouteContentWrapper, {
|
|
190
|
+
key: `suspense-loading-${id}`,
|
|
191
|
+
content:
|
|
192
|
+
resolvedComponent instanceof Promise
|
|
193
|
+
? resolvedComponent
|
|
194
|
+
: Promise.resolve(resolvedComponent),
|
|
195
|
+
fallback: loading,
|
|
196
|
+
})
|
|
197
|
+
: resolvedComponent;
|
|
198
|
+
|
|
199
|
+
// Common props for OutletProvider
|
|
200
|
+
const outletContent: ReactNode =
|
|
201
|
+
node.segment.type === "layout" ? content : null;
|
|
202
|
+
|
|
203
|
+
// Prepare loader data if there are loaders
|
|
204
|
+
const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
|
|
205
|
+
const loaderDataPromise =
|
|
206
|
+
loaderEntries.length > 0
|
|
207
|
+
? Promise.all(
|
|
208
|
+
loaderEntries.map((loader) =>
|
|
209
|
+
loader.loaderData instanceof Promise
|
|
210
|
+
? loader.loaderData
|
|
211
|
+
: Promise.resolve(loader.loaderData)
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
: Promise.resolve([]);
|
|
215
|
+
|
|
216
|
+
// Use LoaderBoundary when loading is defined to maintain consistent tree structure
|
|
217
|
+
// This ensures cached segments (which may not have loader segments) have the same
|
|
218
|
+
// tree structure as fresh segments, preventing React remounts
|
|
219
|
+
// If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
|
|
220
|
+
if (loading !== undefined) {
|
|
221
|
+
content = createElement(LoaderBoundary, {
|
|
222
|
+
key: `loader-boundary-${key}`,
|
|
223
|
+
loaderDataPromise:
|
|
224
|
+
forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
|
|
225
|
+
loaderIds,
|
|
226
|
+
fallback: loading,
|
|
227
|
+
outletKey: key,
|
|
228
|
+
outletContent,
|
|
229
|
+
segment: node.segment,
|
|
230
|
+
parallel: node.parallel,
|
|
231
|
+
children: nodeContent,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// No loading skeleton defined - use OutletProvider directly
|
|
237
|
+
if (loaderEntries.length === 0) {
|
|
238
|
+
// No loaders, no loading - simple OutletProvider
|
|
239
|
+
content = createElement(OutletProvider, {
|
|
240
|
+
key,
|
|
241
|
+
content: outletContent,
|
|
242
|
+
segment: node.segment,
|
|
243
|
+
parallel: node.parallel,
|
|
244
|
+
children: nodeContent,
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Has loaders but no loading skeleton - await loaders and render directly
|
|
250
|
+
const resolvedData = await loaderDataPromise;
|
|
251
|
+
const { loaderData, errorFallback } = resolveLoaderData(
|
|
252
|
+
resolvedData,
|
|
253
|
+
loaderIds
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
content = createElement(OutletProvider, {
|
|
257
|
+
key,
|
|
258
|
+
content: outletContent,
|
|
259
|
+
segment: node.segment,
|
|
260
|
+
parallel: node.parallel,
|
|
261
|
+
loaderData: Object.keys(loaderData).length > 0 ? loaderData : undefined,
|
|
262
|
+
children: errorFallback ?? nodeContent,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Always wrap with root error boundary to prevent white screens
|
|
267
|
+
// This catches any unhandled errors that bubble up from the segment tree
|
|
268
|
+
const errorBoundaryWrapped = createElement(RootErrorBoundary, {
|
|
269
|
+
children: content,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// If rootLayout is provided, wrap the error boundary with it
|
|
273
|
+
// This ensures the app shell stays mounted even during errors (prevents FOUC)
|
|
274
|
+
if (RootLayout) {
|
|
275
|
+
return createElement(RootLayout, {
|
|
276
|
+
children: errorBoundaryWrapped,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return errorBoundaryWrapped;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Walk segments in bottom-to-top order for React nesting
|
|
285
|
+
*
|
|
286
|
+
* Segments from match() are in top-to-bottom order (root → leaf):
|
|
287
|
+
* Example: [L0, L0L0, L0R1L0.@sidebar, L0R1L0, L0R1]
|
|
288
|
+
*
|
|
289
|
+
* For proper React rendering, we need bottom-to-top (leaf → root):
|
|
290
|
+
* - Innermost content (route) wraps inside layouts
|
|
291
|
+
* - Each layer provides context via OutletProvider
|
|
292
|
+
* - Outer layouts receive inner content via <Outlet />
|
|
293
|
+
*
|
|
294
|
+
* Parallel segments must be matched to their parent by ID prefix:
|
|
295
|
+
* - "L0R1L0.@sidebar" belongs to "L0R1L0"
|
|
296
|
+
* - Pre-grouping prevents parallels from attaching to wrong parents
|
|
297
|
+
* during the reversed iteration (which would cause "L0R1L0.@sidebar"
|
|
298
|
+
* to incorrectly attach to "L0L0" instead of "L0R1L0")
|
|
299
|
+
*
|
|
300
|
+
* Loader segments are also grouped by parent:
|
|
301
|
+
* - "L0D0.cart" belongs to "L0"
|
|
302
|
+
* - Loaders don't render directly, their data is passed to context
|
|
303
|
+
*
|
|
304
|
+
* Intercept segments are passed separately for explicit handling:
|
|
305
|
+
* - They are injected into the correct parent's parallel array
|
|
306
|
+
* - This makes the flow clearer than relying on ID pattern matching
|
|
307
|
+
*
|
|
308
|
+
* @param segments - Main segments from the route tree
|
|
309
|
+
* @param interceptSegments - Optional intercept segments to inject
|
|
310
|
+
*/
|
|
311
|
+
function* segmentTreeWalk(
|
|
312
|
+
segments: ResolvedSegment[],
|
|
313
|
+
interceptSegments?: ResolvedSegment[]
|
|
314
|
+
): Generator<{
|
|
315
|
+
segment: ResolvedSegment;
|
|
316
|
+
parallel: ResolvedSegment[];
|
|
317
|
+
loaders: ResolvedSegment[];
|
|
318
|
+
}> {
|
|
319
|
+
// Pre-group parallel and loader segments by their parent ID using prefix matching
|
|
320
|
+
// This ensures each parallel/loader is associated with the correct parent segment
|
|
321
|
+
// regardless of the iteration order
|
|
322
|
+
const parallelsByParent = new Map<string, ResolvedSegment[]>();
|
|
323
|
+
const loadersByParent = new Map<string, ResolvedSegment[]>();
|
|
324
|
+
const nonParallels: ResolvedSegment[] = [];
|
|
325
|
+
|
|
326
|
+
for (const segment of segments) {
|
|
327
|
+
if (segment.type === "parallel") {
|
|
328
|
+
// Extract parent ID from parallel ID
|
|
329
|
+
// Example: "L0R1L0.@sidebar" → "L0R1L0"
|
|
330
|
+
const parentId = segment.id.split(".")[0];
|
|
331
|
+
if (!parallelsByParent.has(parentId)) {
|
|
332
|
+
parallelsByParent.set(parentId, []);
|
|
333
|
+
}
|
|
334
|
+
parallelsByParent.get(parentId)!.push(segment);
|
|
335
|
+
} else if (segment.type === "loader") {
|
|
336
|
+
// Extract parent ID from loader ID
|
|
337
|
+
// Example: "L0D0.cart" → "L0"
|
|
338
|
+
// Loader ID format: {parentShortCode}D{index}.{loaderId}
|
|
339
|
+
const parentId = segment.id.split("D")[0];
|
|
340
|
+
if (!loadersByParent.has(parentId)) {
|
|
341
|
+
loadersByParent.set(parentId, []);
|
|
342
|
+
}
|
|
343
|
+
loadersByParent.get(parentId)!.push(segment);
|
|
344
|
+
} else {
|
|
345
|
+
// Layout, route, error, and notFound segments are all rendered in the tree
|
|
346
|
+
// Error/notFound segments replace the failed segment with fallback UI
|
|
347
|
+
nonParallels.push(segment);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// INTERCEPT SEGMENTS: Explicitly inject into parent's parallel array
|
|
352
|
+
// Intercept segments are passed separately for explicit handling
|
|
353
|
+
if (interceptSegments && interceptSegments.length > 0) {
|
|
354
|
+
for (const intercept of interceptSegments) {
|
|
355
|
+
if (intercept.type === "parallel" && intercept.slot) {
|
|
356
|
+
// Extract parent ID from intercept ID (e.g., "M4L0L0L2.@modal" → "M4L0L0L2")
|
|
357
|
+
const parentId = intercept.id.split(".")[0];
|
|
358
|
+
if (!parallelsByParent.has(parentId)) {
|
|
359
|
+
parallelsByParent.set(parentId, []);
|
|
360
|
+
}
|
|
361
|
+
parallelsByParent.get(parentId)!.push(intercept);
|
|
362
|
+
} else if (intercept.type === "loader") {
|
|
363
|
+
// Intercept loaders - extract parent from loader ID
|
|
364
|
+
const parentId = intercept.id.split("D")[0];
|
|
365
|
+
if (!loadersByParent.has(parentId)) {
|
|
366
|
+
loadersByParent.set(parentId, []);
|
|
367
|
+
}
|
|
368
|
+
loadersByParent.get(parentId)!.push(intercept);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Sort segments by ID to ensure consistent root-to-leaf ordering
|
|
374
|
+
// regardless of the order they arrive in the input array (which can differ
|
|
375
|
+
// between document requests and actions)
|
|
376
|
+
// Shorter IDs come first (closer to root), same length sorted lexicographically
|
|
377
|
+
nonParallels.sort((a, b) => {
|
|
378
|
+
if (a.id.length !== b.id.length) {
|
|
379
|
+
return a.id.length - b.id.length;
|
|
380
|
+
}
|
|
381
|
+
return a.id.localeCompare(b.id);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Iterate bottom-to-top using reverse() to process leaf segments first
|
|
385
|
+
// This processes route/leaf layouts first, then parent layouts
|
|
386
|
+
// Note: We reverse the array to iterate from end to start (bottom-to-top)
|
|
387
|
+
for (let i = nonParallels.length - 1; i >= 0; i--) {
|
|
388
|
+
const segment = nonParallels[i];
|
|
389
|
+
|
|
390
|
+
// Lookup parallels and loaders that belong to this segment by ID prefix
|
|
391
|
+
const parallel = parallelsByParent.get(segment.id) || [];
|
|
392
|
+
const loaders = loadersByParent.get(segment.id) || [];
|
|
393
|
+
|
|
394
|
+
// Also include loaders from parallel segments (e.g., intercept loaders)
|
|
395
|
+
// These have parent IDs like "M9L0L1.@modal" which match the parallel segment ID
|
|
396
|
+
for (const p of parallel) {
|
|
397
|
+
const parallelLoaders = loadersByParent.get(p.id);
|
|
398
|
+
if (parallelLoaders) {
|
|
399
|
+
loaders.push(...parallelLoaders);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
yield { segment, parallel, loaders };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for RequestContext, specifically the onResponse callback API
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
createRequestContext,
|
|
7
|
+
runWithRequestContext,
|
|
8
|
+
getRequestContext,
|
|
9
|
+
} from "../request-context.js";
|
|
10
|
+
|
|
11
|
+
describe("RequestContext", () => {
|
|
12
|
+
describe("onResponse", () => {
|
|
13
|
+
it("should register callbacks", () => {
|
|
14
|
+
const ctx = createRequestContext({
|
|
15
|
+
env: {},
|
|
16
|
+
request: new Request("https://example.com"),
|
|
17
|
+
url: new URL("https://example.com"),
|
|
18
|
+
variables: {},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(ctx._onResponseCallbacks).toHaveLength(0);
|
|
22
|
+
|
|
23
|
+
ctx.onResponse((res) => res);
|
|
24
|
+
expect(ctx._onResponseCallbacks).toHaveLength(1);
|
|
25
|
+
|
|
26
|
+
ctx.onResponse((res) => res);
|
|
27
|
+
expect(ctx._onResponseCallbacks).toHaveLength(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should allow callbacks to inspect response status", () => {
|
|
31
|
+
const ctx = createRequestContext({
|
|
32
|
+
env: {},
|
|
33
|
+
request: new Request("https://example.com"),
|
|
34
|
+
url: new URL("https://example.com"),
|
|
35
|
+
variables: {},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let capturedStatus: number | undefined;
|
|
39
|
+
|
|
40
|
+
ctx.onResponse((res) => {
|
|
41
|
+
capturedStatus = res.status;
|
|
42
|
+
return res;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Simulate calling the callback
|
|
46
|
+
const response = new Response("OK", { status: 200 });
|
|
47
|
+
for (const callback of ctx._onResponseCallbacks) {
|
|
48
|
+
callback(response);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(capturedStatus).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should allow callbacks to modify response", () => {
|
|
55
|
+
const ctx = createRequestContext({
|
|
56
|
+
env: {},
|
|
57
|
+
request: new Request("https://example.com"),
|
|
58
|
+
url: new URL("https://example.com"),
|
|
59
|
+
variables: {},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
ctx.onResponse((res) => {
|
|
63
|
+
// Return a new response with added header
|
|
64
|
+
const newHeaders = new Headers(res.headers);
|
|
65
|
+
newHeaders.set("X-Modified", "true");
|
|
66
|
+
return new Response(res.body, {
|
|
67
|
+
status: res.status,
|
|
68
|
+
headers: newHeaders,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let response = new Response("OK", { status: 200 });
|
|
73
|
+
for (const callback of ctx._onResponseCallbacks) {
|
|
74
|
+
response = callback(response);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(response.headers.get("X-Modified")).toBe("true");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should run multiple callbacks in order", () => {
|
|
81
|
+
const ctx = createRequestContext({
|
|
82
|
+
env: {},
|
|
83
|
+
request: new Request("https://example.com"),
|
|
84
|
+
url: new URL("https://example.com"),
|
|
85
|
+
variables: {},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const order: number[] = [];
|
|
89
|
+
|
|
90
|
+
ctx.onResponse((res) => {
|
|
91
|
+
order.push(1);
|
|
92
|
+
return res;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ctx.onResponse((res) => {
|
|
96
|
+
order.push(2);
|
|
97
|
+
return res;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
ctx.onResponse((res) => {
|
|
101
|
+
order.push(3);
|
|
102
|
+
return res;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let response = new Response("OK");
|
|
106
|
+
for (const callback of ctx._onResponseCallbacks) {
|
|
107
|
+
response = callback(response) ?? response;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expect(order).toEqual([1, 2, 3]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should allow callbacks to chain modifications", () => {
|
|
114
|
+
const ctx = createRequestContext({
|
|
115
|
+
env: {},
|
|
116
|
+
request: new Request("https://example.com"),
|
|
117
|
+
url: new URL("https://example.com"),
|
|
118
|
+
variables: {},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
ctx.onResponse((res) => {
|
|
122
|
+
const newHeaders = new Headers(res.headers);
|
|
123
|
+
newHeaders.set("X-First", "1");
|
|
124
|
+
return new Response(res.body, { status: res.status, headers: newHeaders });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
ctx.onResponse((res) => {
|
|
128
|
+
const newHeaders = new Headers(res.headers);
|
|
129
|
+
newHeaders.set("X-Second", "2");
|
|
130
|
+
return new Response(res.body, { status: res.status, headers: newHeaders });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let response = new Response("OK");
|
|
134
|
+
for (const callback of ctx._onResponseCallbacks) {
|
|
135
|
+
response = callback(response) ?? response;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expect(response.headers.get("X-First")).toBe("1");
|
|
139
|
+
expect(response.headers.get("X-Second")).toBe("2");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should be accessible via getRequestContext", () => {
|
|
143
|
+
const ctx = createRequestContext({
|
|
144
|
+
env: {},
|
|
145
|
+
request: new Request("https://example.com"),
|
|
146
|
+
url: new URL("https://example.com"),
|
|
147
|
+
variables: {},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let callbackCalled = false;
|
|
151
|
+
|
|
152
|
+
runWithRequestContext(ctx, () => {
|
|
153
|
+
const currentCtx = getRequestContext();
|
|
154
|
+
currentCtx?.onResponse((res) => {
|
|
155
|
+
callbackCalled = true;
|
|
156
|
+
return res;
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(ctx._onResponseCallbacks).toHaveLength(1);
|
|
161
|
+
|
|
162
|
+
// Trigger callback
|
|
163
|
+
const response = new Response("OK");
|
|
164
|
+
for (const callback of ctx._onResponseCallbacks) {
|
|
165
|
+
callback(response);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
expect(callbackCalled).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|