@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.
- package/dist/_chunks/{actions-DAVm0FQe.js → actions-Cw-7NfJJ.js} +8 -1
- package/dist/_chunks/actions-Cw-7NfJJ.js.map +1 -0
- package/dist/_chunks/{resolve-schema-DO8B0mO9.js → resolve-schema-23Mc8aLi.js} +1 -1
- package/dist/_chunks/{resolve-schema-DO8B0mO9.js.map → resolve-schema-23Mc8aLi.js.map} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
- package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/fonts/virtual-modules.d.ts +0 -9
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +12 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +2 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +68 -67
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +2 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts +18 -0
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +9 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +7 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +41 -0
- package/dist/server/tree-match.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/fonts/virtual-modules.ts +8 -2
- package/src/plugins/fonts.ts +2 -0
- package/src/server/als-registry.ts +12 -0
- package/src/server/html-injector-core.ts +15 -1
- package/src/server/html-injectors.ts +6 -4
- package/src/server/node-stream-transforms.ts +3 -1
- package/src/server/param-coercion.ts +58 -0
- package/src/server/pipeline-phases.ts +5 -0
- package/src/server/pipeline.ts +7 -0
- package/src/server/request-context.ts +31 -0
- package/src/server/route-element-builder.ts +12 -2
- package/src/server/route-matcher.ts +8 -168
- package/src/server/rsc-entry/rsc-stream.ts +4 -0
- package/src/server/rsc-entry/ssr-renderer.ts +16 -0
- package/src/server/slot-resolver.ts +79 -112
- package/src/server/ssr-entry.ts +15 -1
- package/src/server/tree-match.ts +198 -0
- 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
|
|
76
|
-
*
|
|
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
|
|
84
|
-
|
|
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
|
|
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
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
-
//
|
|
399
|
+
// Match remaining target parts against the intercepting child's sub-tree.
|
|
433
400
|
const remaining = targetParts.slice(matchIdx + 1);
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -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(
|
|
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 = {
|