@timber-js/app 0.2.0-alpha.116 → 0.2.0-alpha.118

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 (63) hide show
  1. package/dist/_chunks/{actions-DAVm0FQe.js → actions-Cw-7NfJJ.js} +8 -1
  2. package/dist/_chunks/actions-Cw-7NfJJ.js.map +1 -0
  3. package/dist/_chunks/{resolve-schema-DO8B0mO9.js → resolve-schema-23Mc8aLi.js} +1 -1
  4. package/dist/_chunks/{resolve-schema-DO8B0mO9.js.map → resolve-schema-23Mc8aLi.js.map} +1 -1
  5. package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
  6. package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
  7. package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client/internal.js +1 -1
  10. package/dist/fonts/virtual-modules.d.ts +0 -9
  11. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  12. package/dist/index.js +9 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/params/index.js +1 -1
  15. package/dist/plugins/fonts.d.ts.map +1 -1
  16. package/dist/server/als-registry.d.ts +12 -0
  17. package/dist/server/als-registry.d.ts.map +1 -1
  18. package/dist/server/html-injector-core.d.ts +2 -1
  19. package/dist/server/html-injector-core.d.ts.map +1 -1
  20. package/dist/server/html-injectors.d.ts +1 -1
  21. package/dist/server/html-injectors.d.ts.map +1 -1
  22. package/dist/server/index.js +1 -1
  23. package/dist/server/internal.js +68 -67
  24. package/dist/server/internal.js.map +1 -1
  25. package/dist/server/node-stream-transforms.d.ts +2 -0
  26. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  27. package/dist/server/param-coercion.d.ts +18 -0
  28. package/dist/server/param-coercion.d.ts.map +1 -1
  29. package/dist/server/pipeline-phases.d.ts.map +1 -1
  30. package/dist/server/pipeline.d.ts +7 -0
  31. package/dist/server/pipeline.d.ts.map +1 -1
  32. package/dist/server/request-context.d.ts +9 -0
  33. package/dist/server/request-context.d.ts.map +1 -1
  34. package/dist/server/route-element-builder.d.ts.map +1 -1
  35. package/dist/server/route-matcher.d.ts.map +1 -1
  36. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
  37. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  38. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  39. package/dist/server/slot-resolver.d.ts +1 -1
  40. package/dist/server/slot-resolver.d.ts.map +1 -1
  41. package/dist/server/ssr-entry.d.ts +7 -0
  42. package/dist/server/ssr-entry.d.ts.map +1 -1
  43. package/dist/server/tree-match.d.ts +41 -0
  44. package/dist/server/tree-match.d.ts.map +1 -0
  45. package/package.json +1 -1
  46. package/src/fonts/virtual-modules.ts +8 -2
  47. package/src/plugins/fonts.ts +2 -0
  48. package/src/server/als-registry.ts +12 -0
  49. package/src/server/html-injector-core.ts +15 -1
  50. package/src/server/html-injectors.ts +6 -4
  51. package/src/server/node-stream-transforms.ts +3 -1
  52. package/src/server/param-coercion.ts +58 -0
  53. package/src/server/pipeline-phases.ts +5 -0
  54. package/src/server/pipeline.ts +7 -0
  55. package/src/server/request-context.ts +31 -0
  56. package/src/server/route-element-builder.ts +12 -2
  57. package/src/server/route-matcher.ts +8 -168
  58. package/src/server/rsc-entry/rsc-stream.ts +4 -0
  59. package/src/server/rsc-entry/ssr-renderer.ts +16 -0
  60. package/src/server/slot-resolver.ts +79 -112
  61. package/src/server/ssr-entry.ts +15 -1
  62. package/src/server/tree-match.ts +198 -0
  63. package/dist/_chunks/actions-DAVm0FQe.js.map +0 -1
@@ -17,6 +17,7 @@ import {
17
17
  resolveStaticContentType,
18
18
  type MetadataRouteType,
19
19
  } from './metadata-routes.js';
20
+ import { matchUrlParts } from './tree-match.js';
20
21
 
21
22
  // ─── Manifest Types ───────────────────────────────────────────────────────
22
23
  //
@@ -72,24 +73,19 @@ export function createRouteMatcher(
72
73
  /**
73
74
  * Match a canonical pathname against the segment tree.
74
75
  *
75
- * Splits the pathname into segments and walks the tree depth-first.
76
- * Returns the segment chain and extracted params on match.
76
+ * Splits the pathname into segments and delegates to the shared
77
+ * tree walker. Collects middleware from the matched segment chain.
77
78
  */
78
79
  function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch | null {
79
- // Split pathname into segments: "/blog/hello-world" → ["blog", "hello-world"]
80
- // "/" → [] (empty segments)
81
80
  const parts = pathname === '/' ? [] : pathname.slice(1).split('/');
82
81
 
83
- const segments: ManifestSegmentNode[] = [];
84
- const params: Record<string, string | string[]> = {};
85
-
86
- const matched = matchSegments(root, parts, 0, segments, params);
87
- if (!matched) return null;
82
+ const result = matchUrlParts(root, parts);
83
+ if (!result) return null;
88
84
 
89
85
  // Collect middleware from all segments in the chain, root to leaf.
90
86
  // Each middleware is lazy-loaded via dynamic import on first invocation.
91
87
  const middlewareChain: MiddlewareFn[] = [];
92
- for (const segment of segments) {
88
+ for (const segment of result.chain) {
93
89
  if (segment.middleware) {
94
90
  const loader = segment.middleware.load;
95
91
  middlewareChain.push(async (ctx) => {
@@ -102,168 +98,12 @@ function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch
102
98
  }
103
99
 
104
100
  return {
105
- segments,
106
- segmentParams: params,
101
+ segments: result.chain,
102
+ segmentParams: result.params,
107
103
  middlewareChain,
108
104
  };
109
105
  }
110
106
 
111
- /**
112
- * An effective child flattened through group segments.
113
- * Includes the chain of group nodes that must be added to the segments
114
- * array before the child itself (to preserve the group → child nesting
115
- * that the renderer expects).
116
- */
117
- interface EffectiveChild {
118
- child: ManifestSegmentNode;
119
- groupChain: ManifestSegmentNode[];
120
- }
121
-
122
- /**
123
- * Collect effective children by flattening through group segments.
124
- *
125
- * Groups are transparent for URL matching — their non-group descendants
126
- * are returned with the chain of group nodes that lead to them. This
127
- * allows the caller to apply priority ordering (static > dynamic > ...)
128
- * across all groups uniformly instead of per-group.
129
- */
130
- function collectEffectiveChildren(
131
- node: ManifestSegmentNode,
132
- groupChain: ManifestSegmentNode[] = []
133
- ): EffectiveChild[] {
134
- const result: EffectiveChild[] = [];
135
- for (const child of node.children) {
136
- if (child.segmentType === 'group') {
137
- // Look through the group — its children become effective children
138
- // with this group prepended to their chain
139
- const nested = collectEffectiveChildren(child, [...groupChain, child]);
140
- result.push(...nested);
141
- } else {
142
- result.push({ child, groupChain });
143
- }
144
- }
145
- return result;
146
- }
147
-
148
- /**
149
- * Recursively match URL segments against the segment tree.
150
- *
151
- * Priority order for children at each level:
152
- * 1. Static segments (exact match)
153
- * 2. Dynamic segments ([param])
154
- * 3. Catch-all segments ([...param])
155
- * 4. Optional catch-all segments ([[...param]])
156
- *
157
- * Groups are transparent — they don't consume URL segments. Children
158
- * are flattened through groups so that priority ordering applies across
159
- * all groups uniformly (a static in group A always beats a dynamic in
160
- * group B, regardless of group ordering).
161
- */
162
- function matchSegments(
163
- node: ManifestSegmentNode,
164
- parts: string[],
165
- index: number,
166
- segments: ManifestSegmentNode[],
167
- params: Record<string, string | string[]>
168
- ): boolean {
169
- segments.push(node);
170
-
171
- // All parts consumed — check if this node has a page or route
172
- if (index >= parts.length) {
173
- if (node.page || node.route) {
174
- return true;
175
- }
176
-
177
- // Check group children (they don't consume URL segments)
178
- for (const child of node.children) {
179
- if (child.segmentType === 'group') {
180
- if (matchSegments(child, parts, index, segments, params)) {
181
- return true;
182
- }
183
- }
184
- }
185
-
186
- // Check optional catch-all children (direct and through groups)
187
- const effective = collectEffectiveChildren(node);
188
- for (const { child, groupChain } of effective) {
189
- if (child.segmentType === 'optional-catch-all') {
190
- if (child.page || child.route) {
191
- segments.push(...groupChain, child);
192
- // Zero segments → param is undefined (not set), matching Next.js semantics
193
- return true;
194
- }
195
- }
196
- }
197
-
198
- segments.pop();
199
- return false;
200
- }
201
-
202
- const part = parts[index];
203
-
204
- // Flatten children through groups so priority ordering applies globally
205
- // across all groups, not per-group.
206
- const effective = collectEffectiveChildren(node);
207
-
208
- // 1. Static segments
209
- for (const { child, groupChain } of effective) {
210
- if (child.segmentType === 'static' && child.segmentName === part) {
211
- segments.push(...groupChain);
212
- if (matchSegments(child, parts, index + 1, segments, params)) {
213
- return true;
214
- }
215
- // Backtrack group chain
216
- segments.length -= groupChain.length;
217
- }
218
- }
219
-
220
- // 2. Dynamic segments ([param])
221
- for (const { child, groupChain } of effective) {
222
- if (child.segmentType === 'dynamic' && child.paramName) {
223
- segments.push(...groupChain);
224
- const prevParam = params[child.paramName];
225
- params[child.paramName] = part;
226
- if (matchSegments(child, parts, index + 1, segments, params)) {
227
- return true;
228
- }
229
- // Backtrack
230
- segments.length -= groupChain.length;
231
- if (prevParam !== undefined) {
232
- params[child.paramName] = prevParam;
233
- } else {
234
- delete params[child.paramName];
235
- }
236
- }
237
- }
238
-
239
- // 3. Catch-all segments ([...param])
240
- for (const { child, groupChain } of effective) {
241
- if (child.segmentType === 'catch-all' && child.paramName) {
242
- if (child.page || child.route) {
243
- const remaining = parts.slice(index);
244
- segments.push(...groupChain, child);
245
- params[child.paramName] = remaining;
246
- return true;
247
- }
248
- }
249
- }
250
-
251
- // 4. Optional catch-all segments ([[...param]])
252
- for (const { child, groupChain } of effective) {
253
- if (child.segmentType === 'optional-catch-all' && child.paramName) {
254
- if (child.page || child.route) {
255
- const remaining = parts.slice(index);
256
- segments.push(...groupChain, child);
257
- params[child.paramName] = remaining;
258
- return true;
259
- }
260
- }
261
- }
262
-
263
- segments.pop();
264
- return false;
265
- }
266
-
267
107
  // ─── Metadata Route Matcher ─────────────────────────────────────────────
268
108
 
269
109
  /** Result of matching a metadata route. */
@@ -53,6 +53,9 @@ export interface RenderSignals {
53
53
  lastUnhandledError: unknown | null;
54
54
  /** Callback fired when a redirect or deny signal is captured in onError. */
55
55
  onSignal?: () => void;
56
+ /** Callback fired when an unhandled error (non-signal) is captured. Used
57
+ * to halt Flight data injection into the inline stream. */
58
+ onUnhandledError?: () => void;
56
59
  }
57
60
 
58
61
  export interface RscStreamResult {
@@ -165,6 +168,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
165
168
  // if SSR fails (SsrStreamError). This is NOT used for page-level
166
169
  // error detection — only as a fallback when SSR actually fails.
167
170
  signals.lastUnhandledError = error;
171
+ signals.onUnhandledError?.();
168
172
 
169
173
  // Return a digest so React emits a per-row error in the Flight
170
174
  // stream instead of leaving the lazy reference unresolved.
@@ -124,6 +124,14 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
124
124
  [ssrStream, inlineStream] = teeWithErrorPropagation(rscStream);
125
125
  }
126
126
 
127
+ // Wire an AbortController so the Flight injector stops writing <script>
128
+ // tags when an unhandled RSC error is detected. Without this, React's
129
+ // Flight encoder may write model data for a chunk that already received
130
+ // an error row, causing the client's Flight decoder to crash with
131
+ // "enqueueModel is not a function". See TIM-938.
132
+ const errorAbort = new AbortController();
133
+ signals.onUnhandledError = () => errorAbort.abort();
134
+
127
135
  // Embed segment metadata in HTML for initial hydration.
128
136
  // The client reads this to populate its segment cache before the
129
137
  // first navigation, enabling state tree diffing from the start.
@@ -159,6 +167,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
159
167
  deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
160
168
  signal: req.signal,
161
169
  cookies: getCookiesForSsr(),
170
+ _errorSignal: errorAbort.signal,
162
171
  };
163
172
 
164
173
  // Helper: check if render-phase signals were captured and return the
@@ -280,6 +289,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
280
289
  // Connection abort — the client disconnected (page refresh, navigation
281
290
  // away). No response needed; return empty 499 (client closed request).
282
291
  if (isAbortError(ssrError) || req.signal?.aborted) {
292
+ inlineStream?.cancel().catch(() => {});
283
293
  return new Response(null, { status: 499 });
284
294
  }
285
295
 
@@ -302,6 +312,12 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
302
312
  ssrError instanceof SsrStreamError ||
303
313
  (ssrError instanceof Error && ssrError.name === 'SsrStreamError');
304
314
  if (isSsrStreamError) {
315
+ // Cancel the teed inline stream — SSR failed so this response will
316
+ // be replaced by renderErrorPage which creates its own clean streams.
317
+ // Without explicit cancellation the orphaned tee branch keeps the
318
+ // RSC source stream alive, preventing garbage collection.
319
+ inlineStream?.cancel().catch(() => {});
320
+
305
321
  if (signals.redirectSignal) {
306
322
  return buildRedirectResponse(req, signals.redirectSignal, responseHeaders);
307
323
  }
@@ -26,6 +26,9 @@ import type { InterceptionContext, RouteMatch } from './pipeline.js';
26
26
  import { DenySignal, RedirectSignal } from './primitives.js';
27
27
  import { logRenderError } from './logger.js';
28
28
  import type { ManifestSegmentNode } from './route-matcher.js';
29
+ import { setSlotParams } from './request-context.js';
30
+ import { coerceSlotParams } from './param-coercion.js';
31
+ import { matchUrlParts } from './tree-match.js';
29
32
 
30
33
  type CreateElementFn = (...args: unknown[]) => React.ReactElement;
31
34
 
@@ -57,89 +60,6 @@ async function renderDefaultFallback(
57
60
  return h(DefaultComp, {});
58
61
  }
59
62
 
60
- // ─── Segment Tree Matching ──────────────────────────────────────────────────
61
-
62
- /**
63
- * Find a matching child node for a URL segment name.
64
- *
65
- * Tries matches in priority order:
66
- * 1. Static segment (exact name match)
67
- * 2. Dynamic segment ([param])
68
- * 3. Catch-all or optional-catch-all ([...param] / [[...param]])
69
- * 4. Group children (transparent wrappers)
70
- *
71
- * Returns `{ node, consumesRest }` where `consumesRest` is true for catch-all
72
- * segments that consume all remaining URL parts.
73
- */
74
- function findMatchingChild(
75
- children: ManifestSegmentNode[],
76
- segmentName: string
77
- ): { node: ManifestSegmentNode; consumesRest: boolean } | null {
78
- // 1. Static match
79
- for (const child of children) {
80
- if (child.segmentType === 'static' && child.segmentName === segmentName) {
81
- return { node: child, consumesRest: false };
82
- }
83
- }
84
-
85
- // 2. Dynamic match
86
- for (const child of children) {
87
- if (child.segmentType === 'dynamic') {
88
- return { node: child, consumesRest: false };
89
- }
90
- }
91
-
92
- // 3. Catch-all match — consumes all remaining segments
93
- for (const child of children) {
94
- if (child.segmentType === 'catch-all' || child.segmentType === 'optional-catch-all') {
95
- return { node: child, consumesRest: true };
96
- }
97
- }
98
-
99
- // 4. Group children (transparent)
100
- for (const child of children) {
101
- if (child.segmentType === 'group') {
102
- for (const groupChild of child.children ?? []) {
103
- if (groupChild.segmentName === segmentName) {
104
- return { node: groupChild, consumesRest: false };
105
- }
106
- }
107
- }
108
- }
109
-
110
- return null;
111
- }
112
-
113
- /**
114
- * Walk a segment tree from `startNode`, matching each part in `parts` against
115
- * child nodes. Returns the chain of matched nodes (including startNode) and the
116
- * final node, or null if any part fails to match.
117
- */
118
- function walkSegmentTree(
119
- startNode: ManifestSegmentNode,
120
- parts: { segmentName: string }[] | string[],
121
- initialChain: ManifestSegmentNode[] = [startNode]
122
- ): { chain: ManifestSegmentNode[]; leaf: ManifestSegmentNode } | null {
123
- const chain = [...initialChain];
124
- let currentNode = startNode;
125
-
126
- for (const part of parts) {
127
- const segName = typeof part === 'string' ? part : part.segmentName;
128
- const directChildren = currentNode.children ?? [];
129
- const match = findMatchingChild(directChildren, segName);
130
-
131
- if (!match) return null;
132
-
133
- chain.push(match.node);
134
- currentNode = match.node;
135
-
136
- // Catch-all segments consume all remaining parts
137
- if (match.consumesRest) break;
138
- }
139
-
140
- return { chain, leaf: currentNode };
141
- }
142
-
143
63
  // ─── Slot Element Resolution ────────────────────────────────────────────────
144
64
 
145
65
  /**
@@ -157,7 +77,8 @@ export async function resolveSlotElement(
157
77
  slotNode: ManifestSegmentNode,
158
78
  match: RouteMatch,
159
79
  h: CreateElementFn,
160
- interception?: InterceptionContext
80
+ interception?: InterceptionContext,
81
+ parentTreePath?: string
161
82
  ): Promise<React.ReactElement | null> {
162
83
  // When interception is active, try to match intercepting children in this
163
84
  // slot against the target pathname. If an intercepting child matches, render
@@ -169,6 +90,22 @@ export async function resolveSlotElement(
169
90
  : findSlotMatch(slotNode, match);
170
91
 
171
92
  if (slotMatch) {
93
+ // Store per-slot params in ALS so getSegmentParams(segmentPath) returns
94
+ // the slot's own param types (e.g. catch-all → string[] vs dynamic → string).
95
+ if (parentTreePath && Object.keys(slotMatch.slotParams).length > 0) {
96
+ // Build the full tree path: parent prefix + slot chain segment names.
97
+ const slotSuffix = slotMatch.chain
98
+ .map((s) => s.segmentName)
99
+ .filter(Boolean)
100
+ .join('/');
101
+ const prefix = parentTreePath === '/' ? '' : parentTreePath;
102
+ const fullSlotPath = `${prefix}/${slotSuffix}`;
103
+
104
+ // Coerce slot params through global schema codecs (same as main route).
105
+ const coerced = coerceSlotParams(slotMatch.chain, slotMatch.slotParams);
106
+ setSlotParams(fullSlotPath, coerced);
107
+ }
108
+
172
109
  const SlotPage = await loadComponent(slotMatch.page);
173
110
  if (SlotPage) {
174
111
  // Load default.tsx fallback for notFound() handling in the slot page.
@@ -334,6 +271,8 @@ interface SlotMatchResult {
334
271
  page: NonNullable<ManifestSegmentNode['page']>;
335
272
  /** The full chain of slot nodes traversed (slot root → … → leaf with page). */
336
273
  chain: ManifestSegmentNode[];
274
+ /** Params collected from the slot's segment chain (may differ from main route params). */
275
+ slotParams: Record<string, string | string[]>;
337
276
  }
338
277
 
339
278
  /**
@@ -374,26 +313,54 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
374
313
  // against the slot's children.
375
314
  const remainingSegments = parentIndex >= 0 ? segments.slice(parentIndex + 1) : [];
376
315
 
377
- // If no remaining segments, the slot's own page matches
378
- if (remainingSegments.length === 0) {
379
- if (slotNode.page) {
380
- return { page: slotNode.page, chain: [slotNode] };
381
- }
382
- // Check for optional-catch-all child — [[...slug]] matches zero segments
383
- for (const child of slotNode.children ?? []) {
384
- if (child.segmentType === 'optional-catch-all' && child.page) {
385
- return { page: child.page, chain: [slotNode, child] };
316
+ // Extract raw URL parts from the remaining main-route segments.
317
+ // Use rawSegmentParams (pre-coercion) to ensure we get actual URL strings,
318
+ // not codec-transformed values. Fall back to segmentParams for tests/compat.
319
+ const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
320
+ const urlParts = extractUrlParts(remainingSegments, rawParams);
321
+
322
+ // Match URL parts against the slot's segment tree using the shared walker.
323
+ // Handles zero parts (slot's own page), catch-all, optional-catch-all,
324
+ // groups, backtracking, and correct priority ordering.
325
+ const result = matchUrlParts(slotNode, urlParts);
326
+ if (!result) return null;
327
+
328
+ const leaf = result.chain[result.chain.length - 1];
329
+ if (!leaf.page) return null;
330
+
331
+ return { page: leaf.page, chain: result.chain, slotParams: result.params };
332
+ }
333
+
334
+ /**
335
+ * Extract raw URL path parts from a main-route segment chain.
336
+ *
337
+ * Each dynamic segment consumed one URL part (its param value).
338
+ * Static segments consumed their own name. Groups are transparent.
339
+ * Catch-all segments consumed multiple parts (their param value is string[]).
340
+ *
341
+ * Uses rawSegmentParams (pre-coercion) to get actual URL strings.
342
+ * Values are coerced to String() defensively in case a codec leaked
343
+ * a non-string value into the params map.
344
+ */
345
+ function extractUrlParts(
346
+ segments: ManifestSegmentNode[],
347
+ mainParams: Record<string, string | string[]>
348
+ ): string[] {
349
+ const parts: string[] = [];
350
+ for (const seg of segments) {
351
+ if (seg.segmentType === 'group') continue;
352
+ if (seg.segmentType === 'static') {
353
+ parts.push(seg.segmentName);
354
+ } else if (seg.paramName) {
355
+ const value = mainParams[seg.paramName];
356
+ if (Array.isArray(value)) {
357
+ parts.push(...value.map(String));
358
+ } else if (value !== undefined) {
359
+ parts.push(String(value));
386
360
  }
387
361
  }
388
- return null;
389
- }
390
-
391
- // Walk the slot's children to match remaining URL segments
392
- const result = walkSegmentTree(slotNode, remainingSegments);
393
- if (result && result.leaf.page) {
394
- return { page: result.leaf.page, chain: result.chain };
395
362
  }
396
- return null;
363
+ return parts;
397
364
  }
398
365
 
399
366
  /**
@@ -429,19 +396,19 @@ function findInterceptingMatch(
429
396
  }
430
397
  if (matchIdx < 0) continue;
431
398
 
432
- // Walk the intercepting child's sub-tree to match remaining target parts
399
+ // Match remaining target parts against the intercepting child's sub-tree.
433
400
  const remaining = targetParts.slice(matchIdx + 1);
434
-
435
- if (remaining.length === 0) {
436
- if (child.page) {
437
- return { page: child.page, chain: [slotNode, child] };
401
+ const result = matchUrlParts(child, remaining);
402
+ if (result) {
403
+ const leaf = result.chain[result.chain.length - 1];
404
+ if (leaf.page) {
405
+ return {
406
+ page: leaf.page,
407
+ // Prepend slotNode — matchUrlParts starts from child, not slot root
408
+ chain: [slotNode, ...result.chain],
409
+ slotParams: result.params,
410
+ };
438
411
  }
439
- continue;
440
- }
441
-
442
- const result = walkSegmentTree(child, remaining, [slotNode, child]);
443
- if (result && result.leaf.page) {
444
- return { page: result.leaf.page, chain: result.chain };
445
412
  }
446
413
  }
447
414
 
@@ -157,6 +157,14 @@ export interface NavContext {
157
157
  /** Whether the Node.js native stream path was used */
158
158
  nodeStreams: boolean;
159
159
  };
160
+
161
+ /**
162
+ * Signal that fires when an unhandled RSC error is detected.
163
+ * The Flight injector checks this before writing each script tag —
164
+ * when aborted, it stops injecting Flight data to prevent corrupted
165
+ * chunks from crashing the client's Flight decoder.
166
+ */
167
+ _errorSignal?: AbortSignal;
160
168
  }
161
169
 
162
170
  /**
@@ -291,6 +299,7 @@ export async function handleSsr(
291
299
  const headInjector = createNodeHeadInjector(navContext.headHtml);
292
300
  const flightInjector = createNodeFlightInjector(navContext.rscStream, {
293
301
  renderTimeoutMs,
302
+ errorSignal: navContext._errorSignal,
294
303
  });
295
304
  const moveSuffix = createNodeMoveSuffixTransform();
296
305
 
@@ -350,7 +359,12 @@ export async function handleSsr(
350
359
  // structure (TIM-527).
351
360
  let outputStream = htmlStream.pipeThrough(createBufferedTransformStream());
352
361
  outputStream = injectHead(outputStream, navContext.headHtml);
353
- outputStream = injectRscPayload(outputStream, navContext.rscStream, renderTimeoutMs);
362
+ outputStream = injectRscPayload(
363
+ outputStream,
364
+ navContext.rscStream,
365
+ renderTimeoutMs,
366
+ navContext._errorSignal
367
+ );
354
368
  const _pipelineEnd = performance.now();
355
369
 
356
370
  navContext._ssrTimings = {