@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.
Files changed (155) hide show
  1. package/CLAUDE.md +7 -0
  2. package/README.md +19 -0
  3. package/dist/vite/index.js +1298 -0
  4. package/package.json +140 -0
  5. package/skills/caching/SKILL.md +319 -0
  6. package/skills/document-cache/SKILL.md +152 -0
  7. package/skills/hooks/SKILL.md +359 -0
  8. package/skills/intercept/SKILL.md +292 -0
  9. package/skills/layout/SKILL.md +216 -0
  10. package/skills/loader/SKILL.md +365 -0
  11. package/skills/middleware/SKILL.md +442 -0
  12. package/skills/parallel/SKILL.md +255 -0
  13. package/skills/route/SKILL.md +141 -0
  14. package/skills/router-setup/SKILL.md +403 -0
  15. package/skills/theme/SKILL.md +54 -0
  16. package/skills/typesafety/SKILL.md +352 -0
  17. package/src/__mocks__/version.ts +6 -0
  18. package/src/__tests__/component-utils.test.ts +76 -0
  19. package/src/__tests__/route-definition.test.ts +63 -0
  20. package/src/__tests__/urls.test.tsx +436 -0
  21. package/src/browser/event-controller.ts +876 -0
  22. package/src/browser/index.ts +18 -0
  23. package/src/browser/link-interceptor.ts +121 -0
  24. package/src/browser/lru-cache.ts +69 -0
  25. package/src/browser/merge-segment-loaders.ts +126 -0
  26. package/src/browser/navigation-bridge.ts +893 -0
  27. package/src/browser/navigation-client.ts +162 -0
  28. package/src/browser/navigation-store.ts +823 -0
  29. package/src/browser/partial-update.ts +559 -0
  30. package/src/browser/react/Link.tsx +248 -0
  31. package/src/browser/react/NavigationProvider.tsx +275 -0
  32. package/src/browser/react/ScrollRestoration.tsx +94 -0
  33. package/src/browser/react/context.ts +53 -0
  34. package/src/browser/react/index.ts +52 -0
  35. package/src/browser/react/location-state-shared.ts +120 -0
  36. package/src/browser/react/location-state.ts +62 -0
  37. package/src/browser/react/use-action.ts +240 -0
  38. package/src/browser/react/use-client-cache.ts +56 -0
  39. package/src/browser/react/use-handle.ts +178 -0
  40. package/src/browser/react/use-href.tsx +208 -0
  41. package/src/browser/react/use-link-status.ts +134 -0
  42. package/src/browser/react/use-navigation.ts +150 -0
  43. package/src/browser/react/use-segments.ts +188 -0
  44. package/src/browser/request-controller.ts +164 -0
  45. package/src/browser/rsc-router.tsx +353 -0
  46. package/src/browser/scroll-restoration.ts +324 -0
  47. package/src/browser/server-action-bridge.ts +747 -0
  48. package/src/browser/shallow.ts +35 -0
  49. package/src/browser/types.ts +464 -0
  50. package/src/cache/__tests__/document-cache.test.ts +522 -0
  51. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  52. package/src/cache/__tests__/memory-store.test.ts +484 -0
  53. package/src/cache/cache-scope.ts +565 -0
  54. package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
  55. package/src/cache/cf/cf-cache-store.ts +428 -0
  56. package/src/cache/cf/index.ts +19 -0
  57. package/src/cache/document-cache.ts +340 -0
  58. package/src/cache/index.ts +58 -0
  59. package/src/cache/memory-segment-store.ts +150 -0
  60. package/src/cache/memory-store.ts +253 -0
  61. package/src/cache/types.ts +387 -0
  62. package/src/client.rsc.tsx +88 -0
  63. package/src/client.tsx +621 -0
  64. package/src/component-utils.ts +76 -0
  65. package/src/components/DefaultDocument.tsx +23 -0
  66. package/src/default-error-boundary.tsx +88 -0
  67. package/src/deps/browser.ts +8 -0
  68. package/src/deps/html-stream-client.ts +2 -0
  69. package/src/deps/html-stream-server.ts +2 -0
  70. package/src/deps/rsc.ts +10 -0
  71. package/src/deps/ssr.ts +2 -0
  72. package/src/errors.ts +259 -0
  73. package/src/handle.ts +120 -0
  74. package/src/handles/MetaTags.tsx +193 -0
  75. package/src/handles/index.ts +6 -0
  76. package/src/handles/meta.ts +247 -0
  77. package/src/href-client.ts +128 -0
  78. package/src/href-context.ts +33 -0
  79. package/src/href.ts +177 -0
  80. package/src/index.rsc.ts +79 -0
  81. package/src/index.ts +87 -0
  82. package/src/loader.rsc.ts +204 -0
  83. package/src/loader.ts +47 -0
  84. package/src/network-error-thrower.tsx +21 -0
  85. package/src/outlet-context.ts +15 -0
  86. package/src/root-error-boundary.tsx +277 -0
  87. package/src/route-content-wrapper.tsx +198 -0
  88. package/src/route-definition.ts +1371 -0
  89. package/src/route-map-builder.ts +146 -0
  90. package/src/route-types.ts +198 -0
  91. package/src/route-utils.ts +89 -0
  92. package/src/router/__tests__/match-context.test.ts +104 -0
  93. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  94. package/src/router/__tests__/match-result.test.ts +566 -0
  95. package/src/router/__tests__/on-error.test.ts +935 -0
  96. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  97. package/src/router/error-handling.ts +287 -0
  98. package/src/router/handler-context.ts +158 -0
  99. package/src/router/loader-resolution.ts +326 -0
  100. package/src/router/manifest.ts +138 -0
  101. package/src/router/match-context.ts +264 -0
  102. package/src/router/match-middleware/background-revalidation.ts +236 -0
  103. package/src/router/match-middleware/cache-lookup.ts +261 -0
  104. package/src/router/match-middleware/cache-store.ts +266 -0
  105. package/src/router/match-middleware/index.ts +81 -0
  106. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  107. package/src/router/match-middleware/segment-resolution.ts +174 -0
  108. package/src/router/match-pipelines.ts +214 -0
  109. package/src/router/match-result.ts +214 -0
  110. package/src/router/metrics.ts +62 -0
  111. package/src/router/middleware.test.ts +1355 -0
  112. package/src/router/middleware.ts +748 -0
  113. package/src/router/pattern-matching.ts +272 -0
  114. package/src/router/revalidation.ts +190 -0
  115. package/src/router/router-context.ts +299 -0
  116. package/src/router/types.ts +96 -0
  117. package/src/router.ts +3876 -0
  118. package/src/rsc/__tests__/helpers.test.ts +175 -0
  119. package/src/rsc/handler.ts +1060 -0
  120. package/src/rsc/helpers.ts +64 -0
  121. package/src/rsc/index.ts +56 -0
  122. package/src/rsc/nonce.ts +18 -0
  123. package/src/rsc/types.ts +237 -0
  124. package/src/segment-system.tsx +456 -0
  125. package/src/server/__tests__/request-context.test.ts +171 -0
  126. package/src/server/context.ts +417 -0
  127. package/src/server/handle-store.ts +230 -0
  128. package/src/server/loader-registry.ts +174 -0
  129. package/src/server/request-context.ts +554 -0
  130. package/src/server/root-layout.tsx +10 -0
  131. package/src/server/tsconfig.json +14 -0
  132. package/src/server.ts +146 -0
  133. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  134. package/src/ssr/index.tsx +234 -0
  135. package/src/theme/ThemeProvider.tsx +291 -0
  136. package/src/theme/ThemeScript.tsx +61 -0
  137. package/src/theme/__tests__/theme.test.ts +120 -0
  138. package/src/theme/constants.ts +55 -0
  139. package/src/theme/index.ts +58 -0
  140. package/src/theme/theme-context.ts +70 -0
  141. package/src/theme/theme-script.ts +152 -0
  142. package/src/theme/types.ts +182 -0
  143. package/src/theme/use-theme.ts +44 -0
  144. package/src/types.ts +1561 -0
  145. package/src/urls.ts +726 -0
  146. package/src/use-loader.tsx +346 -0
  147. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  148. package/src/vite/expose-action-id.ts +344 -0
  149. package/src/vite/expose-handle-id.ts +209 -0
  150. package/src/vite/expose-loader-id.ts +357 -0
  151. package/src/vite/expose-location-state-id.ts +177 -0
  152. package/src/vite/index.ts +787 -0
  153. package/src/vite/package-resolution.ts +125 -0
  154. package/src/vite/version.d.ts +12 -0
  155. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,456 @@
1
+ import { createElement, type ReactNode, type ComponentType } from "react";
2
+ import { OutletProvider } from "./client.js";
3
+ import { HrefProvider } from "./browser/react/use-href.js";
4
+ import type {
5
+ ResolvedSegment,
6
+ LoaderDataResult,
7
+ RootLayoutProps,
8
+ } from "./types.js";
9
+ import { isLoaderDataResult } from "./types.js";
10
+ import { invariant } from "./errors.js";
11
+ import {
12
+ RouteContentWrapper,
13
+ LoaderBoundary,
14
+ } from "./route-content-wrapper.js";
15
+ import { RootErrorBoundary } from "./root-error-boundary.js";
16
+
17
+ /**
18
+ * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
19
+ */
20
+ function resolveLoaderData(
21
+ resolvedData: any[],
22
+ loaderIds: string[],
23
+ ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
24
+ const loaderData: Record<string, any> = {};
25
+ let errorFallback: ReactNode = null;
26
+
27
+ for (let i = 0; i < loaderIds.length; i++) {
28
+ const id = loaderIds[i];
29
+ const result = resolvedData[i];
30
+
31
+ if (!isLoaderDataResult(result)) {
32
+ // Legacy format - direct data
33
+ loaderData[id] = result;
34
+ continue;
35
+ }
36
+
37
+ if (result.ok) {
38
+ loaderData[id] = result.data;
39
+ continue;
40
+ }
41
+
42
+ // Error case
43
+ if (result.fallback) {
44
+ errorFallback = result.fallback;
45
+ } else {
46
+ throw new Error(result.error.message);
47
+ }
48
+ }
49
+
50
+ return { loaderData, errorFallback };
51
+ }
52
+
53
+ /**
54
+ * Options for renderSegments
55
+ */
56
+ export interface RenderSegmentsOptions {
57
+ /**
58
+ * If true, this render is for a server action response.
59
+ * In browser during actions, we await component promises to prevent
60
+ * UI flickering/suspense during optimistic updates.
61
+ */
62
+ isAction?: boolean;
63
+
64
+ /**
65
+ * If true, force awaiting all loaders instead of streaming with Suspense.
66
+ * Used for popstate (back/forward) navigation where we want instant rendering
67
+ * from cache without showing loading skeletons.
68
+ */
69
+ forceAwait?: boolean;
70
+
71
+ /**
72
+ * Intercept segments to inject into the tree.
73
+ * These are parallel segments from intercept routes that need to be
74
+ * associated with their parent layout's named outlet.
75
+ *
76
+ * Passed separately for explicit handling - makes the flow clearer
77
+ * and easier to debug than relying on ID pattern matching.
78
+ */
79
+ interceptSegments?: ResolvedSegment[];
80
+
81
+ /**
82
+ * Root layout component that wraps the entire application.
83
+ * When provided, wraps both route content and the error boundary,
84
+ * preventing the app shell from unmounting during errors (avoids FOUC).
85
+ */
86
+ rootLayout?: ComponentType<RootLayoutProps>;
87
+
88
+ /**
89
+ * Route map for useHref() during SSR.
90
+ * Maps route names to URL patterns.
91
+ */
92
+ routeMap?: Record<string, string>;
93
+
94
+ /**
95
+ * Current matched route name for useHref() during SSR.
96
+ * Used for local name resolution.
97
+ */
98
+ routeName?: string;
99
+ }
100
+
101
+ /**
102
+ * Render segments into a React tree with proper layout nesting
103
+ *
104
+ * Layouts nest using OutletProvider, while route + parallel + error + notFound segments
105
+ * render as siblings in a Fragment.
106
+ *
107
+ * Error segments are treated like route segments - they render their fallback
108
+ * component in place of the failed segment. When an error occurs in a handler,
109
+ * loader, or middleware, the router creates an error segment with the nearest
110
+ * error boundary's fallback component.
111
+ *
112
+ * NotFound segments are similar to error segments but are triggered by
113
+ * DataNotFoundError (thrown via notFound()). They render the nearest
114
+ * notFoundBoundary's fallback component.
115
+ *
116
+ * @param segments - Array of resolved segments to render
117
+ * @returns ReactNode representing the component tree
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * const segments = [
122
+ * { id: 'L0.0', type: 'layout', component: <RootLayout /> },
123
+ * { id: 'L1.0', type: 'layout', component: <BlogLayout /> },
124
+ * { id: 'R2.0', type: 'route', component: <BlogPost /> },
125
+ * { id: 'P3.0', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
126
+ * ];
127
+ *
128
+ * const tree = renderSegments(segments);
129
+ * // Results in:
130
+ * // <OutletProvider><RootLayout>
131
+ * // <OutletProvider><BlogLayout>
132
+ * // <><BlogPost /><Sidebar /></>
133
+ * // </BlogLayout></OutletProvider>
134
+ * // </RootLayout></OutletProvider>
135
+ *
136
+ * // For server actions, pass isAction to await components:
137
+ * const tree = renderSegments(segments, { isAction: true });
138
+ * ```
139
+ */
140
+ export async function renderSegments(
141
+ segments: ResolvedSegment[],
142
+ options?: RenderSegmentsOptions,
143
+ ): Promise<ReactNode> {
144
+ const {
145
+ isAction,
146
+ interceptSegments,
147
+ forceAwait,
148
+ rootLayout: RootLayout,
149
+ routeMap,
150
+ routeName,
151
+ } = options || {};
152
+ const temporalLazyRefs: Promise<any>[] = [];
153
+
154
+ /**
155
+ * Registers promises from lazy/async components for awaiting.
156
+ * Handles both direct promises and React lazy components (which store promise in _payload).
157
+ */
158
+ const registerLazyRef = <T,>(node: T): T => {
159
+ if (node instanceof Promise) {
160
+ temporalLazyRefs.push(node);
161
+ } else if (isLazyComponent(node)) {
162
+ temporalLazyRefs.push(node._payload);
163
+ }
164
+ return node;
165
+ };
166
+
167
+ // Type guard for React lazy component internals
168
+ function isLazyComponent(node: unknown): node is { _payload: Promise<any> } {
169
+ return (
170
+ node != null &&
171
+ typeof node === "object" &&
172
+ "_payload" in node &&
173
+ (node as any)._payload instanceof Promise
174
+ );
175
+ }
176
+ // Separate segments by type, passing intercept segments for explicit injection
177
+ const tree = segmentTreeWalk(segments, interceptSegments);
178
+ // Render content segments as siblings
179
+ let content: ReactNode = null;
180
+ for (const node of tree) {
181
+ invariant(
182
+ node.segment.type === "layout" ||
183
+ node.segment.type === "route" ||
184
+ node.segment.type === "error" ||
185
+ node.segment.type === "notFound",
186
+ `Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
187
+ );
188
+ const { component, id, params, loading } = node.segment;
189
+
190
+ // Only include params in key for segments that belong to the route
191
+ // - Routes: always include params (they render param-specific content)
192
+ // - Error/notFound segments: always include params (they replace failed route content)
193
+ // - Route's layouts (orphans): include params (children of parameterized route)
194
+ // - Parent chain layouts: exclude params (shared across routes, param-agnostic)
195
+ // This prevents unnecessary unmounting when params change
196
+ const includeParams =
197
+ node.segment.type === "route" ||
198
+ node.segment.type === "error" ||
199
+ node.segment.type === "notFound" ||
200
+ (node.segment.type === "layout" && node.segment.belongsToRoute);
201
+
202
+ const paramStr =
203
+ includeParams && params && Object.keys(params).length > 0
204
+ ? Object.entries(params)
205
+ .sort(([a], [b]) => a.localeCompare(b))
206
+ .map(([k, v]) => `${k}=${v}`)
207
+ .join(",")
208
+ : "";
209
+ const key = `${paramStr ? `${id}-${paramStr}` : id}`;
210
+
211
+ // Get loader entries for this node
212
+ const loaderEntries = node.loaders.filter(
213
+ (loader) => loader.loaderId && loader.loaderData !== undefined,
214
+ );
215
+
216
+ // Determine the component content (with or without Suspense wrapper)
217
+ // Wrap when loading skeleton defined OR component is Promise (needs Suspense)
218
+ // During actions, await component Promise to prevent Suspense from triggering
219
+ // This keeps existing content visible instead of showing loading skeleton
220
+ let resolvedComponent = component;
221
+ if (isAction && component instanceof Promise) {
222
+ resolvedComponent = await component;
223
+ }
224
+ let nodeContent: ReactNode =
225
+ loading || loading === null || resolvedComponent instanceof Promise
226
+ ? createElement(RouteContentWrapper, {
227
+ key: `suspense-loading-${id}`,
228
+ content:
229
+ resolvedComponent instanceof Promise
230
+ ? resolvedComponent
231
+ : Promise.resolve(resolvedComponent),
232
+ fallback: loading,
233
+ })
234
+ : registerLazyRef(resolvedComponent);
235
+
236
+ // Common props for OutletProvider
237
+ const outletContent: ReactNode =
238
+ node.segment.type === "layout" ? content : null;
239
+
240
+ // Prepare loader data if there are loaders
241
+ const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
242
+ const loaderDataPromise =
243
+ loaderEntries.length > 0
244
+ ? Promise.all(
245
+ loaderEntries.map((loader) =>
246
+ loader.loaderData instanceof Promise
247
+ ? loader.loaderData
248
+ : Promise.resolve(loader.loaderData),
249
+ ),
250
+ )
251
+ : Promise.resolve([]);
252
+
253
+ // Use LoaderBoundary when loading is defined to maintain consistent tree structure
254
+ // This ensures cached segments (which may not have loader segments) have the same
255
+ // tree structure as fresh segments, preventing React remounts
256
+ // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
257
+ if (loading !== undefined) {
258
+ content = createElement(LoaderBoundary, {
259
+ key: `loader-boundary-${key}`,
260
+ loaderDataPromise:
261
+ forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
262
+ loaderIds,
263
+ fallback: loading,
264
+ outletKey: key,
265
+ outletContent,
266
+ segment: node.segment,
267
+ parallel: node.parallel,
268
+ children: nodeContent,
269
+ });
270
+ continue;
271
+ }
272
+
273
+ // No loading skeleton defined - use OutletProvider directly
274
+ if (loaderEntries.length === 0) {
275
+ // No loaders, no loading - simple OutletProvider
276
+ content = createElement(OutletProvider, {
277
+ key,
278
+ content: outletContent,
279
+ segment: node.segment,
280
+ parallel: node.parallel,
281
+ children: nodeContent,
282
+ });
283
+ continue;
284
+ }
285
+
286
+ // Has loaders but no loading skeleton - await loaders and render directly
287
+ const resolvedData = await loaderDataPromise;
288
+ const { loaderData, errorFallback } = resolveLoaderData(
289
+ resolvedData,
290
+ loaderIds,
291
+ );
292
+
293
+ content = createElement(OutletProvider, {
294
+ key,
295
+ content: outletContent,
296
+ segment: node.segment,
297
+ parallel: node.parallel,
298
+ loaderData: Object.keys(loaderData).length > 0 ? loaderData : undefined,
299
+ children: errorFallback ?? nodeContent,
300
+ });
301
+ }
302
+
303
+ // Always wrap with root error boundary to prevent white screens
304
+ // This catches any unhandled errors that bubble up from the segment tree
305
+ const errorBoundaryWrapped = createElement(RootErrorBoundary, {
306
+ children: content,
307
+ });
308
+ await Promise.allSettled(temporalLazyRefs);
309
+
310
+ // Build the final result, optionally wrapped with root layout
311
+ let result: ReactNode = errorBoundaryWrapped;
312
+
313
+ // If rootLayout is provided, wrap the error boundary with it
314
+ // This ensures the app shell stays mounted even during errors (prevents FOUC)
315
+ if (RootLayout) {
316
+ result = createElement(RootLayout, {
317
+ children: errorBoundaryWrapped,
318
+ });
319
+ }
320
+
321
+ // Wrap with HrefProvider for useHref() to work during SSR
322
+ // This provides route info to client components during server rendering
323
+ if (routeMap) {
324
+ result = createElement(HrefProvider, {
325
+ routeMap,
326
+ routeName,
327
+ children: result,
328
+ });
329
+ }
330
+
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * Walk segments in bottom-to-top order for React nesting
336
+ *
337
+ * Segments from match() are in top-to-bottom order (root → leaf):
338
+ * Example: [L0, L0L0, L0R1L0.@sidebar, L0R1L0, L0R1]
339
+ *
340
+ * For proper React rendering, we need bottom-to-top (leaf → root):
341
+ * - Innermost content (route) wraps inside layouts
342
+ * - Each layer provides context via OutletProvider
343
+ * - Outer layouts receive inner content via <Outlet />
344
+ *
345
+ * Parallel segments must be matched to their parent by ID prefix:
346
+ * - "L0R1L0.@sidebar" belongs to "L0R1L0"
347
+ * - Pre-grouping prevents parallels from attaching to wrong parents
348
+ * during the reversed iteration (which would cause "L0R1L0.@sidebar"
349
+ * to incorrectly attach to "L0L0" instead of "L0R1L0")
350
+ *
351
+ * Loader segments are also grouped by parent:
352
+ * - "L0D0.cart" belongs to "L0"
353
+ * - Loaders don't render directly, their data is passed to context
354
+ *
355
+ * Intercept segments are passed separately for explicit handling:
356
+ * - They are injected into the correct parent's parallel array
357
+ * - This makes the flow clearer than relying on ID pattern matching
358
+ *
359
+ * @param segments - Main segments from the route tree
360
+ * @param interceptSegments - Optional intercept segments to inject
361
+ */
362
+ function* segmentTreeWalk(
363
+ segments: ResolvedSegment[],
364
+ interceptSegments?: ResolvedSegment[],
365
+ ): Generator<{
366
+ segment: ResolvedSegment;
367
+ parallel: ResolvedSegment[];
368
+ loaders: ResolvedSegment[];
369
+ }> {
370
+ // Pre-group parallel and loader segments by their parent ID using prefix matching
371
+ // This ensures each parallel/loader is associated with the correct parent segment
372
+ // regardless of the iteration order
373
+ const parallelsByParent = new Map<string, ResolvedSegment[]>();
374
+ const loadersByParent = new Map<string, ResolvedSegment[]>();
375
+ const nonParallels: ResolvedSegment[] = [];
376
+
377
+ for (const segment of segments) {
378
+ if (segment.type === "parallel") {
379
+ // Extract parent ID from parallel ID
380
+ // Example: "L0R1L0.@sidebar" → "L0R1L0"
381
+ const parentId = segment.id.split(".")[0];
382
+ if (!parallelsByParent.has(parentId)) {
383
+ parallelsByParent.set(parentId, []);
384
+ }
385
+ parallelsByParent.get(parentId)!.push(segment);
386
+ } else if (segment.type === "loader") {
387
+ // Extract parent ID from loader ID
388
+ // Example: "L0D0.cart" → "L0"
389
+ // Loader ID format: {parentShortCode}D{index}.{loaderId}
390
+ const parentId = segment.id.split("D")[0];
391
+ if (!loadersByParent.has(parentId)) {
392
+ loadersByParent.set(parentId, []);
393
+ }
394
+ loadersByParent.get(parentId)!.push(segment);
395
+ } else {
396
+ // Layout, route, error, and notFound segments are all rendered in the tree
397
+ // Error/notFound segments replace the failed segment with fallback UI
398
+ nonParallels.push(segment);
399
+ }
400
+ }
401
+
402
+ // INTERCEPT SEGMENTS: Explicitly inject into parent's parallel array
403
+ // Intercept segments are passed separately for explicit handling
404
+ if (interceptSegments && interceptSegments.length > 0) {
405
+ for (const intercept of interceptSegments) {
406
+ if (intercept.type === "parallel" && intercept.slot) {
407
+ // Extract parent ID from intercept ID (e.g., "M4L0L0L2.@modal" → "M4L0L0L2")
408
+ const parentId = intercept.id.split(".")[0];
409
+ if (!parallelsByParent.has(parentId)) {
410
+ parallelsByParent.set(parentId, []);
411
+ }
412
+ parallelsByParent.get(parentId)!.push(intercept);
413
+ } else if (intercept.type === "loader") {
414
+ // Intercept loaders - extract parent from loader ID
415
+ const parentId = intercept.id.split("D")[0];
416
+ if (!loadersByParent.has(parentId)) {
417
+ loadersByParent.set(parentId, []);
418
+ }
419
+ loadersByParent.get(parentId)!.push(intercept);
420
+ }
421
+ }
422
+ }
423
+
424
+ // Sort segments by ID to ensure consistent root-to-leaf ordering
425
+ // regardless of the order they arrive in the input array (which can differ
426
+ // between document requests and actions)
427
+ // Shorter IDs come first (closer to root), same length sorted lexicographically
428
+ nonParallels.sort((a, b) => {
429
+ if (a.id.length !== b.id.length) {
430
+ return a.id.length - b.id.length;
431
+ }
432
+ return a.id.localeCompare(b.id);
433
+ });
434
+
435
+ // Iterate bottom-to-top using reverse() to process leaf segments first
436
+ // This processes route/leaf layouts first, then parent layouts
437
+ // Note: We reverse the array to iterate from end to start (bottom-to-top)
438
+ for (let i = nonParallels.length - 1; i >= 0; i--) {
439
+ const segment = nonParallels[i];
440
+
441
+ // Lookup parallels and loaders that belong to this segment by ID prefix
442
+ const parallel = parallelsByParent.get(segment.id) || [];
443
+ const loaders = loadersByParent.get(segment.id) || [];
444
+
445
+ // Also include loaders from parallel segments (e.g., intercept loaders)
446
+ // These have parent IDs like "M9L0L1.@modal" which match the parallel segment ID
447
+ for (const p of parallel) {
448
+ const parallelLoaders = loadersByParent.get(p.id);
449
+ if (parallelLoaders) {
450
+ loaders.push(...parallelLoaders);
451
+ }
452
+ }
453
+
454
+ yield { segment, parallel, loaders };
455
+ }
456
+ }
@@ -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
+ });