@rangojs/router 0.0.0-experimental.130 → 0.0.0-experimental.132
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/bin/rango.js +69 -24
- package/dist/vite/index.js +182 -41
- package/package.json +6 -3
- package/src/browser/connection-warmup.ts +134 -0
- package/src/browser/event-controller.ts +5 -4
- package/src/browser/partial-update.ts +32 -16
- package/src/browser/react/NavigationProvider.tsx +6 -83
- package/src/browser/react/filter-segment-order.ts +17 -0
- package/src/browser/react/use-link-status.ts +10 -2
- package/src/browser/react/use-navigation.ts +10 -2
- package/src/build/route-types/ast-route-extraction.ts +15 -8
- package/src/build/route-types/include-resolution.ts +109 -21
- package/src/build/route-types/per-module-writer.ts +15 -2
- package/src/cache/cache-key-utils.ts +29 -13
- package/src/cache/cf/cf-cache-store.ts +129 -5
- package/src/decode-loader-results.ts +11 -1
- package/src/encode-kv.ts +49 -0
- package/src/handles/meta.ts +5 -1
- package/src/host/cookie-handler.ts +2 -21
- package/src/prerender/param-hash.ts +6 -5
- package/src/regex-escape.ts +8 -0
- package/src/route-definition/dsl-helpers.ts +6 -2
- package/src/router/error-handling.ts +32 -1
- package/src/router/handler-context.ts +6 -1
- package/src/router/instrument.ts +56 -14
- package/src/router/intercept-resolution.ts +16 -1
- package/src/router/loader-resolution.ts +49 -19
- package/src/router/match-middleware/background-revalidation.ts +6 -0
- package/src/router/match-middleware/cache-store.ts +6 -0
- package/src/router/middleware.ts +67 -27
- package/src/router/pattern-matching.ts +3 -9
- package/src/router/revalidation.ts +65 -23
- package/src/router/router-context.ts +1 -0
- package/src/router/router-options.ts +3 -3
- package/src/router/segment-resolution/fresh.ts +8 -9
- package/src/router/segment-resolution/helpers.ts +11 -10
- package/src/router/segment-resolution/loader-cache.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +4 -4
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/trie-matching.ts +74 -20
- package/src/router.ts +2 -2
- package/src/rsc/progressive-enhancement.ts +20 -0
- package/src/rsc/server-action.ts +124 -47
- package/src/search-params.ts +8 -6
- package/src/segment-system.tsx +7 -1
- package/src/server/cookie-parse.ts +32 -0
- package/src/server/handle-store.ts +14 -14
- package/src/server/request-context.ts +5 -26
- package/src/ssr/index.tsx +5 -4
- package/src/testing/render-handler.ts +11 -0
- package/src/vite/plugins/expose-id-utils.ts +77 -2
- package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
- package/src/vite/utils/prerender-utils.ts +1 -3
|
@@ -81,6 +81,7 @@ export function tryTrieMatch(
|
|
|
81
81
|
result.wildcardValue,
|
|
82
82
|
pathname,
|
|
83
83
|
pathnameHasTrailingSlash,
|
|
84
|
+
result.validatedParams,
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -91,6 +92,14 @@ interface WalkResult {
|
|
|
91
92
|
leaf: TrieLeaf;
|
|
92
93
|
paramValues: string[];
|
|
93
94
|
wildcardValue?: string;
|
|
95
|
+
/**
|
|
96
|
+
* For a constraint-bearing leaf (leaf.cv set), the params map that
|
|
97
|
+
* leafConstraintsPass already decoded AND validated during the walk. Carried
|
|
98
|
+
* forward so validateAndBuild reuses it instead of re-decoding paramValues and
|
|
99
|
+
* re-running constraintsSatisfied a second time on the winning leaf. Undefined
|
|
100
|
+
* for unconstrained leaves (no decode/validate happened in the walk).
|
|
101
|
+
*/
|
|
102
|
+
validatedParams?: Record<string, string>;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
|
@@ -115,17 +124,23 @@ function constraintsSatisfied(
|
|
|
115
124
|
/**
|
|
116
125
|
* Constraint check for a candidate terminal DURING the walk. Builds the named
|
|
117
126
|
* params from positional walk values (decoded the same way validateAndBuild
|
|
118
|
-
* does) and validates leaf.cv. Returning
|
|
127
|
+
* does) and validates leaf.cv. Returning null lets walkTrie unwind to a
|
|
119
128
|
* lower-priority sibling instead of committing to a leaf that would only be
|
|
120
129
|
* rejected post-walk — that post-walk rejection is what forced the regex
|
|
121
130
|
* fallback (and its false "trie gap" R3 warning) for perfectly valid configs.
|
|
131
|
+
*
|
|
132
|
+
* On success returns the built+validated params for a constraint-bearing leaf so
|
|
133
|
+
* walkTrie can carry them to validateAndBuild (avoiding a second decode + a
|
|
134
|
+
* second constraintsSatisfied pass on the winner); returns the shared EMPTY_PASS
|
|
135
|
+
* sentinel for an unconstrained leaf (no work was done, nothing to carry).
|
|
122
136
|
*/
|
|
137
|
+
const EMPTY_PASS: Record<string, string> = {};
|
|
123
138
|
function leafConstraintsPass(
|
|
124
139
|
leaf: TrieLeaf,
|
|
125
140
|
paramValues: string[],
|
|
126
141
|
wildcardValue: string | undefined,
|
|
127
|
-
):
|
|
128
|
-
if (!leaf.cv) return
|
|
142
|
+
): Record<string, string> | null {
|
|
143
|
+
if (!leaf.cv) return EMPTY_PASS;
|
|
129
144
|
const params: Record<string, string> = {};
|
|
130
145
|
if (leaf.pa) {
|
|
131
146
|
for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
|
|
@@ -136,7 +151,7 @@ function leafConstraintsPass(
|
|
|
136
151
|
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
137
152
|
safeDecodeURIComponent(wildcardValue);
|
|
138
153
|
}
|
|
139
|
-
return constraintsSatisfied(leaf, params);
|
|
154
|
+
return constraintsSatisfied(leaf, params) ? params : null;
|
|
140
155
|
}
|
|
141
156
|
|
|
142
157
|
/**
|
|
@@ -154,8 +169,19 @@ function walkTrie(
|
|
|
154
169
|
paramValues: string[],
|
|
155
170
|
): WalkResult | null {
|
|
156
171
|
if (index === segments.length) {
|
|
157
|
-
if (node.r
|
|
158
|
-
|
|
172
|
+
if (node.r) {
|
|
173
|
+
const validatedParams = leafConstraintsPass(
|
|
174
|
+
node.r,
|
|
175
|
+
paramValues,
|
|
176
|
+
undefined,
|
|
177
|
+
);
|
|
178
|
+
if (validatedParams) {
|
|
179
|
+
return {
|
|
180
|
+
leaf: node.r,
|
|
181
|
+
paramValues: [...paramValues],
|
|
182
|
+
validatedParams,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
159
185
|
}
|
|
160
186
|
// A wildcard at this node matches the bare prefix with an empty remainder
|
|
161
187
|
// (e.g. "/files" against "/files/*"), mirroring the regex matcher's `*=""`.
|
|
@@ -163,8 +189,16 @@ function walkTrie(
|
|
|
163
189
|
// so without this a request to the wildcard's own prefix misses the trie
|
|
164
190
|
// and the regex fallback emits a corrupt redirect. A static terminal
|
|
165
191
|
// (node.r) still wins.
|
|
166
|
-
if (node.w
|
|
167
|
-
|
|
192
|
+
if (node.w) {
|
|
193
|
+
const validatedParams = leafConstraintsPass(node.w, paramValues, "");
|
|
194
|
+
if (validatedParams) {
|
|
195
|
+
return {
|
|
196
|
+
leaf: node.w,
|
|
197
|
+
paramValues: [...paramValues],
|
|
198
|
+
wildcardValue: "",
|
|
199
|
+
validatedParams,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
168
202
|
}
|
|
169
203
|
return null;
|
|
170
204
|
}
|
|
@@ -206,11 +240,13 @@ function walkTrie(
|
|
|
206
240
|
|
|
207
241
|
if (node.w) {
|
|
208
242
|
const rest = joinRemainingSegments(segments, index);
|
|
209
|
-
|
|
243
|
+
const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
|
|
244
|
+
if (validatedParams) {
|
|
210
245
|
return {
|
|
211
246
|
leaf: node.w,
|
|
212
247
|
paramValues: [...paramValues],
|
|
213
248
|
wildcardValue: rest,
|
|
249
|
+
validatedParams,
|
|
214
250
|
};
|
|
215
251
|
}
|
|
216
252
|
}
|
|
@@ -230,6 +266,15 @@ function joinRemainingSegments(segments: string[], start: number): string {
|
|
|
230
266
|
|
|
231
267
|
/**
|
|
232
268
|
* Post-match: validate constraints and handle trailing slash logic.
|
|
269
|
+
*
|
|
270
|
+
* `validatedParams` is the params map walkTrie already decoded AND validated via
|
|
271
|
+
* leafConstraintsPass for a constraint-bearing winning leaf. When present (and
|
|
272
|
+
* non-empty) we reuse it verbatim and SKIP the second decode + the second
|
|
273
|
+
* constraintsSatisfied pass — both are byte-identical to the walk-time work.
|
|
274
|
+
* When absent (unconstrained leaf, or the root-path call sites that never walk)
|
|
275
|
+
* we still BUILD the params here (that is not redundant — they must be returned)
|
|
276
|
+
* and run constraintsSatisfied for safety; an unconstrained leaf's check is a
|
|
277
|
+
* cheap early `!leaf.cv` return.
|
|
233
278
|
*/
|
|
234
279
|
function validateAndBuild(
|
|
235
280
|
leaf: TrieLeaf,
|
|
@@ -237,21 +282,30 @@ function validateAndBuild(
|
|
|
237
282
|
wildcardValue: string | undefined,
|
|
238
283
|
originalPathname: string,
|
|
239
284
|
pathnameHasTrailingSlash: boolean,
|
|
285
|
+
validatedParams?: Record<string, string>,
|
|
240
286
|
): TrieMatchResult | null {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
287
|
+
let params: Record<string, string>;
|
|
288
|
+
// EMPTY_PASS (the unconstrained sentinel) and undefined both mean "nothing was
|
|
289
|
+
// pre-validated"; only a populated map carried from a constraint-bearing leaf
|
|
290
|
+
// lets us skip the rebuild + re-check.
|
|
291
|
+
if (validatedParams && validatedParams !== EMPTY_PASS) {
|
|
292
|
+
params = validatedParams;
|
|
293
|
+
} else {
|
|
294
|
+
params = {};
|
|
295
|
+
if (leaf.pa) {
|
|
296
|
+
for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
|
|
297
|
+
params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
|
|
298
|
+
}
|
|
245
299
|
}
|
|
246
|
-
}
|
|
247
300
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
301
|
+
if (wildcardValue !== undefined && "pn" in leaf) {
|
|
302
|
+
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
303
|
+
safeDecodeURIComponent(wildcardValue);
|
|
304
|
+
}
|
|
252
305
|
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
if (!constraintsSatisfied(leaf, params)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
255
309
|
}
|
|
256
310
|
|
|
257
311
|
const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;
|
package/src/router.ts
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
import { loadManifest } from "./router/manifest.js";
|
|
64
64
|
import { createMetricsStore } from "./router/metrics.js";
|
|
65
65
|
import {
|
|
66
|
-
|
|
66
|
+
compileMiddlewarePattern,
|
|
67
67
|
type MiddlewareEntry,
|
|
68
68
|
type MiddlewareFn,
|
|
69
69
|
} from "./router/middleware.js";
|
|
@@ -347,7 +347,7 @@ export function createRouter<TEnv = any>(
|
|
|
347
347
|
let regex: RegExp | null = null;
|
|
348
348
|
let paramNames: string[] = [];
|
|
349
349
|
if (fullPattern) {
|
|
350
|
-
const parsed =
|
|
350
|
+
const parsed = compileMiddlewarePattern(fullPattern);
|
|
351
351
|
regex = parsed.regex;
|
|
352
352
|
paramNames = parsed.paramNames;
|
|
353
353
|
}
|
|
@@ -116,6 +116,13 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
116
116
|
// Execute action and return HTML
|
|
117
117
|
let actionResult: unknown = undefined;
|
|
118
118
|
let reactFormState: ReactFormState | null = null;
|
|
119
|
+
// Status for the fall-through re-render after a boundaryless action error.
|
|
120
|
+
// When an action throws and NO error boundary matches, the PE path re-renders
|
|
121
|
+
// the page (below) the same way it would after a successful action. Without
|
|
122
|
+
// this the re-render serves HTTP 200, diverging from the JS path which serves
|
|
123
|
+
// 500 (server-action.ts sets actionStatus=500 for the same boundaryless error).
|
|
124
|
+
// 500 is carried only into the final HTML response, never the redirect branch.
|
|
125
|
+
let boundarylessErrorStatus: number | undefined;
|
|
119
126
|
|
|
120
127
|
if (isUseActionState) {
|
|
121
128
|
// Decode and extract action identity before execution so error
|
|
@@ -156,6 +163,9 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
156
163
|
handledByBoundary: false,
|
|
157
164
|
});
|
|
158
165
|
console.error("[RSC] Progressive enhancement action error:", error);
|
|
166
|
+
// No boundary matched — the fall-through re-render must carry 500 to match
|
|
167
|
+
// the JS path's boundaryless-error status (server-action.ts).
|
|
168
|
+
boundarylessErrorStatus = 500;
|
|
159
169
|
}
|
|
160
170
|
} else if (isDirectAction && directActionId) {
|
|
161
171
|
const temporaryReferences = ctx.createTemporaryReferenceSet();
|
|
@@ -206,6 +216,9 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
206
216
|
handledByBoundary: false,
|
|
207
217
|
});
|
|
208
218
|
console.error("[RSC] Progressive enhancement action error:", error);
|
|
219
|
+
// No boundary matched — the fall-through re-render must carry 500 to match
|
|
220
|
+
// the JS path's boundaryless-error status (server-action.ts).
|
|
221
|
+
boundarylessErrorStatus = 500;
|
|
209
222
|
}
|
|
210
223
|
}
|
|
211
224
|
|
|
@@ -300,6 +313,13 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
300
313
|
});
|
|
301
314
|
|
|
302
315
|
return createResponseWithMergedHeaders(htmlStream, {
|
|
316
|
+
// boundarylessErrorStatus is set only when the action threw and no error
|
|
317
|
+
// boundary matched; it makes the re-render carry 500 like the JS path.
|
|
318
|
+
// The redirect branch above returns before this, so a redirect re-render
|
|
319
|
+
// keeps its 308 and is never overridden.
|
|
320
|
+
...(boundarylessErrorStatus !== undefined
|
|
321
|
+
? { status: boundarylessErrorStatus }
|
|
322
|
+
: {}),
|
|
303
323
|
headers: { "content-type": "text/html;charset=utf-8" },
|
|
304
324
|
});
|
|
305
325
|
};
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -31,11 +31,20 @@ import {
|
|
|
31
31
|
} from "./helpers.js";
|
|
32
32
|
import { warnNonRedirectActionResponse } from "./runtime-warnings.js";
|
|
33
33
|
import type { HandlerContext } from "./handler-context.js";
|
|
34
|
+
import type { MatchResult } from "../types.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Data flowing from action execution to the revalidation phase.
|
|
37
|
-
* When the action completes without redirect
|
|
38
|
-
*
|
|
38
|
+
* When the action completes without redirect, the handler passes this to route
|
|
39
|
+
* middleware → revalidateAfterAction.
|
|
40
|
+
*
|
|
41
|
+
* `errorBoundary` carries the matched error-boundary result when the action
|
|
42
|
+
* threw and a boundary matched. The error-boundary render is then performed in
|
|
43
|
+
* the revalidation phase so it runs INSIDE the same route-middleware wrapper as
|
|
44
|
+
* a successful revalidation — route middleware (context vars, headers, cookies)
|
|
45
|
+
* must apply to the error render too, matching the module doc's "identical to a
|
|
46
|
+
* normal render". When `errorBoundary` is set, `actionContext` is unused (the
|
|
47
|
+
* boundary is already matched; no matchPartial is run).
|
|
39
48
|
*/
|
|
40
49
|
export interface ActionContinuation {
|
|
41
50
|
returnValue: { ok: boolean; data: unknown };
|
|
@@ -49,6 +58,7 @@ export interface ActionContinuation {
|
|
|
49
58
|
actionResult: unknown;
|
|
50
59
|
formData?: FormData;
|
|
51
60
|
};
|
|
61
|
+
errorBoundary?: MatchResult;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/**
|
|
@@ -78,13 +88,29 @@ export async function executeServerAction<TEnv>(
|
|
|
78
88
|
? await request.formData()
|
|
79
89
|
: await request.text();
|
|
80
90
|
|
|
81
|
-
if (body instanceof FormData) {
|
|
82
|
-
actionFormData = body;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
91
|
if (hasBodyContent(body)) {
|
|
86
92
|
args = await ctx.decodeReply(body, { temporaryReferences });
|
|
87
93
|
}
|
|
94
|
+
|
|
95
|
+
// Surface the action's FormData to shouldRevalidate({ formData }) for a JS
|
|
96
|
+
// server action, matching the PE path (progressive-enhancement.ts populates
|
|
97
|
+
// formData from request.formData()). A form-driven action is invoked as
|
|
98
|
+
// action(formData) (direct) or action(prevState, formData) (useActionState),
|
|
99
|
+
// so the FormData arrives INSIDE the decoded args. Use the LAST FormData arg:
|
|
100
|
+
// for useActionState the submitted form is the final arg, and a prior state
|
|
101
|
+
// that is itself a FormData would otherwise be picked first.
|
|
102
|
+
//
|
|
103
|
+
// The raw request body is NOT usable here: encodeReply wraps a FormData arg
|
|
104
|
+
// in a multipart envelope whose keys are Flight-encoded (e.g. `_1_name`,
|
|
105
|
+
// `0`), so request.formData() would hand shouldRevalidate a FormData with
|
|
106
|
+
// internal keys instead of the consumer's `name`. The decoded arg has the
|
|
107
|
+
// original keys.
|
|
108
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
109
|
+
if (args[i] instanceof FormData) {
|
|
110
|
+
actionFormData = args[i] as FormData;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
} catch (error) {
|
|
89
115
|
// Keep the original error as `cause` for server-side logging, but do not
|
|
90
116
|
// interpolate it into the message: that string can surface to the client
|
|
@@ -123,6 +149,8 @@ export async function executeServerAction<TEnv>(
|
|
|
123
149
|
|
|
124
150
|
returnValue = { ok: true, data };
|
|
125
151
|
} catch (error) {
|
|
152
|
+
let actionResultData: unknown = error;
|
|
153
|
+
|
|
126
154
|
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
127
155
|
if (error instanceof Response) {
|
|
128
156
|
const intercepted = interceptRedirectForPartial(
|
|
@@ -142,9 +170,18 @@ export async function executeServerAction<TEnv>(
|
|
|
142
170
|
`Use \`throw redirect('/path')\` for redirects.`,
|
|
143
171
|
);
|
|
144
172
|
}
|
|
173
|
+
|
|
174
|
+
// A raw Response cannot be serialized into Flight; storing it as the
|
|
175
|
+
// action returnValue.data would make the error payload serialization
|
|
176
|
+
// throw and mask the boundary render. Replace it with a serializable
|
|
177
|
+
// error (mirrors the discard of a returned non-redirect Response above).
|
|
178
|
+
// matchError/onError still receive the original Response.
|
|
179
|
+
actionResultData = new Error(
|
|
180
|
+
`Server action "${actionId}" threw a non-redirect Response (status ${error.status})`,
|
|
181
|
+
);
|
|
145
182
|
}
|
|
146
183
|
|
|
147
|
-
returnValue = { ok: false, data:
|
|
184
|
+
returnValue = { ok: false, data: actionResultData };
|
|
148
185
|
actionStatus = 500;
|
|
149
186
|
|
|
150
187
|
// Try to render error boundary.
|
|
@@ -179,47 +216,27 @@ export async function executeServerAction<TEnv>(
|
|
|
179
216
|
});
|
|
180
217
|
|
|
181
218
|
if (errorResult) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
isPartial: true,
|
|
192
|
-
matched: errorResult.matched,
|
|
193
|
-
diff: errorResult.diff,
|
|
194
|
-
resolvedIds: errorResult.resolvedIds,
|
|
195
|
-
params: errorResult.params,
|
|
196
|
-
isError: true,
|
|
197
|
-
handles: handleStore.stream(),
|
|
198
|
-
version: ctx.version,
|
|
199
|
-
},
|
|
219
|
+
// Defer the error-boundary render to the revalidation phase so it runs
|
|
220
|
+
// inside the same route-middleware wrapper as a successful revalidation
|
|
221
|
+
// (handler.ts executeRenderWithMiddleware). Building + returning the
|
|
222
|
+
// Response here would bypass route middleware: context vars, headers, and
|
|
223
|
+
// cookies set by route middleware would NOT apply to the error render,
|
|
224
|
+
// diverging from the success path and from the module doc's "identical to
|
|
225
|
+
// a normal render". The boundary is already matched; the render in
|
|
226
|
+
// revalidateAfterAction uses errorResult directly (no matchPartial).
|
|
227
|
+
return {
|
|
200
228
|
returnValue,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// Intentionally omit attachLocationState for error payloads:
|
|
204
|
-
// location state is a success-only semantic. Error boundary responses
|
|
205
|
-
// update the error UI but should not mutate browser history state.
|
|
206
|
-
|
|
207
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
229
|
+
actionStatus,
|
|
208
230
|
temporaryReferences,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
status: actionStatus,
|
|
216
|
-
headers: {
|
|
217
|
-
"content-type": "text/x-component;charset=utf-8",
|
|
218
|
-
// Router identity for the client's pre-decode integrity check (the
|
|
219
|
-
// action apply path has no post-decode guard). See response-adapter.
|
|
220
|
-
"X-RSC-Router-Id": ctx.router.id,
|
|
231
|
+
// actionContext is unused on the errorBoundary path (no matchPartial).
|
|
232
|
+
actionContext: {
|
|
233
|
+
actionId,
|
|
234
|
+
actionUrl: new URL(url),
|
|
235
|
+
actionResult: returnValue.data,
|
|
236
|
+
formData: actionFormData,
|
|
221
237
|
},
|
|
222
|
-
|
|
238
|
+
errorBoundary: errorResult,
|
|
239
|
+
};
|
|
223
240
|
}
|
|
224
241
|
}
|
|
225
242
|
|
|
@@ -290,11 +307,71 @@ async function revalidateAfterActionInner<TEnv>(
|
|
|
290
307
|
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
291
308
|
continuation: ActionContinuation,
|
|
292
309
|
): Promise<Response> {
|
|
293
|
-
const {
|
|
294
|
-
|
|
310
|
+
const {
|
|
311
|
+
returnValue,
|
|
312
|
+
actionStatus,
|
|
313
|
+
temporaryReferences,
|
|
314
|
+
actionContext,
|
|
315
|
+
errorBoundary,
|
|
316
|
+
} = continuation;
|
|
295
317
|
const reqCtx = requireRequestContext();
|
|
296
318
|
const metricsStore = reqCtx._metricsStore;
|
|
297
319
|
|
|
320
|
+
// Action threw and a boundary matched: render the (already-matched) error
|
|
321
|
+
// boundary here so it runs inside the route-middleware wrapper, exactly like
|
|
322
|
+
// the success branch below. setRequestContextParams + the payload mirror the
|
|
323
|
+
// pre-deferral render that executeServerAction used to do inline.
|
|
324
|
+
if (errorBoundary) {
|
|
325
|
+
setRequestContextParams(errorBoundary.params, errorBoundary.routeName);
|
|
326
|
+
|
|
327
|
+
const errorPayload: RscPayload = {
|
|
328
|
+
metadata: {
|
|
329
|
+
pathname: url.pathname,
|
|
330
|
+
// routerId exposed for the frontend (current app identity); see
|
|
331
|
+
// rsc-rendering.ts partial branch.
|
|
332
|
+
routerId: ctx.router.id,
|
|
333
|
+
segments: errorBoundary.segments,
|
|
334
|
+
isPartial: true,
|
|
335
|
+
matched: errorBoundary.matched,
|
|
336
|
+
diff: errorBoundary.diff,
|
|
337
|
+
resolvedIds: errorBoundary.resolvedIds,
|
|
338
|
+
params: errorBoundary.params,
|
|
339
|
+
isError: true,
|
|
340
|
+
handles: handleStore.stream(),
|
|
341
|
+
version: ctx.version,
|
|
342
|
+
},
|
|
343
|
+
returnValue,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Intentionally omit attachLocationState for error payloads: location state
|
|
347
|
+
// is a success-only semantic. Error boundary responses update the error UI
|
|
348
|
+
// but should not mutate browser history state.
|
|
349
|
+
|
|
350
|
+
const errorStart = performance.now();
|
|
351
|
+
const errorStream = ctx.renderToReadableStream<RscPayload>(errorPayload, {
|
|
352
|
+
temporaryReferences,
|
|
353
|
+
onError: (error: unknown) => {
|
|
354
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
appendMetric(
|
|
358
|
+
metricsStore,
|
|
359
|
+
"rsc-serialize",
|
|
360
|
+
errorStart,
|
|
361
|
+
performance.now() - errorStart,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
return createResponseWithMergedHeaders(errorStream, {
|
|
365
|
+
status: actionStatus,
|
|
366
|
+
headers: {
|
|
367
|
+
"content-type": "text/x-component;charset=utf-8",
|
|
368
|
+
// Router identity for the client's pre-decode integrity check (the
|
|
369
|
+
// action apply path has no post-decode guard). See response-adapter.
|
|
370
|
+
"X-RSC-Router-Id": ctx.router.id,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
298
375
|
const matchResult = await ctx.router.matchPartial(
|
|
299
376
|
request,
|
|
300
377
|
{ env },
|
package/src/search-params.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* URLSearchParams instance.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { encodeKV } from "./encode-kv.js";
|
|
11
|
+
|
|
10
12
|
/** Supported scalar types for search params (append ? for optional). */
|
|
11
13
|
export type SearchSchemaValue =
|
|
12
14
|
| "string"
|
|
@@ -200,15 +202,15 @@ export function parseSearchParams<T extends SearchSchema>(
|
|
|
200
202
|
|
|
201
203
|
/**
|
|
202
204
|
* Serialize a typed search params object to a query string (without leading `?`).
|
|
203
|
-
* Skips `undefined` and `null` values.
|
|
205
|
+
* Skips `undefined` and `null` values. Preserves insertion order (no sort).
|
|
204
206
|
*/
|
|
205
207
|
export function serializeSearchParams(params: Record<string, unknown>): string {
|
|
206
|
-
|
|
208
|
+
// Pre-filter null/undefined and coerce values to strings here so encodeKV
|
|
209
|
+
// (which never inspects values) reproduces this call site's exact output.
|
|
210
|
+
const pairs: [string, string][] = [];
|
|
207
211
|
for (const [key, value] of Object.entries(params)) {
|
|
208
212
|
if (value === undefined || value === null) continue;
|
|
209
|
-
|
|
210
|
-
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
|
211
|
-
);
|
|
213
|
+
pairs.push([key, String(value)]);
|
|
212
214
|
}
|
|
213
|
-
return
|
|
215
|
+
return encodeKV(pairs);
|
|
214
216
|
}
|
package/src/segment-system.tsx
CHANGED
|
@@ -30,9 +30,15 @@ function isRenderableLoading(loading: ReactNode): boolean {
|
|
|
30
30
|
return loading !== undefined && loading !== null && loading !== false;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Exported for unit testing the no-parallel fast path (D6); internal otherwise.
|
|
34
|
+
export function restoreParallelLoaderMarkers(
|
|
34
35
|
segments: ResolvedSegment[],
|
|
35
36
|
): ResolvedSegment[] {
|
|
37
|
+
// Parallel-loading markers only exist when a parallel segment is present, so
|
|
38
|
+
// a list with no parallel slot has nothing to restore. Skip the Map alloc and
|
|
39
|
+
// full scan in that (common) case — this runs on every render.
|
|
40
|
+
if (!segments.some((s) => s.type === "parallel")) return segments;
|
|
41
|
+
|
|
36
42
|
const parallelLoadingByNamespace = new Map<string, ReactNode>();
|
|
37
43
|
let nextSegments: ResolvedSegment[] | null = null;
|
|
38
44
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical inbound-Cookie-header parser.
|
|
3
|
+
*
|
|
4
|
+
* Kept as a dependency-free leaf so any consumer (request-context, the host
|
|
5
|
+
* dispatcher, tests) can share one implementation without pulling a heavier
|
|
6
|
+
* module's graph. A duplicate copy in middleware-cookies.ts was removed; the
|
|
7
|
+
* host copy in cookie-handler.ts was collapsed onto this one. Not part of the
|
|
8
|
+
* public export surface.
|
|
9
|
+
*/
|
|
10
|
+
export function parseCookiesFromHeader(
|
|
11
|
+
cookieHeader: string | null,
|
|
12
|
+
): Record<string, string> {
|
|
13
|
+
if (!cookieHeader) return {};
|
|
14
|
+
|
|
15
|
+
const cookies: Record<string, string> = {};
|
|
16
|
+
const pairs = cookieHeader.split(";");
|
|
17
|
+
|
|
18
|
+
for (const pair of pairs) {
|
|
19
|
+
const [name, ...rest] = pair.trim().split("=");
|
|
20
|
+
if (name) {
|
|
21
|
+
const raw = rest.join("=");
|
|
22
|
+
try {
|
|
23
|
+
cookies[name] = decodeURIComponent(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
// Malformed percent-encoding: fall back to raw value
|
|
26
|
+
cookies[name] = raw;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return cookies;
|
|
32
|
+
}
|
|
@@ -174,8 +174,10 @@ export function createHandleStore(): HandleStore {
|
|
|
174
174
|
notifyDrain();
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
//
|
|
178
|
-
|
|
177
|
+
// Dirty flag for pending emissions and resolver for waiting consumer.
|
|
178
|
+
// stream() only ever yields the latest full state, so we track a single
|
|
179
|
+
// dirty bit and clone `data` once at yield time instead of per push.
|
|
180
|
+
let hasPendingEmission = false;
|
|
179
181
|
let emissionResolver: (() => void) | null = null;
|
|
180
182
|
let completed = false;
|
|
181
183
|
|
|
@@ -190,7 +192,7 @@ export function createHandleStore(): HandleStore {
|
|
|
190
192
|
|
|
191
193
|
// Wait for the next emission or completion
|
|
192
194
|
function waitForEmission(): Promise<void> {
|
|
193
|
-
if (
|
|
195
|
+
if (hasPendingEmission || completed) {
|
|
194
196
|
return Promise.resolve();
|
|
195
197
|
}
|
|
196
198
|
return new Promise((resolve) => {
|
|
@@ -238,8 +240,8 @@ export function createHandleStore(): HandleStore {
|
|
|
238
240
|
}
|
|
239
241
|
data[handleName][segmentId].push(value);
|
|
240
242
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
+
// Mark dirty; the actual snapshot is cloned once at yield time.
|
|
244
|
+
hasPendingEmission = true;
|
|
243
245
|
signalEmission();
|
|
244
246
|
},
|
|
245
247
|
|
|
@@ -260,22 +262,20 @@ export function createHandleStore(): HandleStore {
|
|
|
260
262
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
261
263
|
|
|
262
264
|
if (Object.keys(data).length > 0) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
yield snapshot;
|
|
265
|
+
hasPendingEmission = false;
|
|
266
|
+
yield cloneHandleData(data);
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
while (!completed) {
|
|
269
270
|
await waitForEmission();
|
|
270
271
|
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
yield latest;
|
|
272
|
+
if (hasPendingEmission) {
|
|
273
|
+
hasPendingEmission = false;
|
|
274
|
+
yield cloneHandleData(data);
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
if (
|
|
278
|
+
if (hasPendingEmission) {
|
|
279
279
|
yield cloneHandleData(data);
|
|
280
280
|
}
|
|
281
281
|
},
|
|
@@ -303,7 +303,7 @@ export function createHandleStore(): HandleStore {
|
|
|
303
303
|
// segment, not accumulate on top of data from a different route.
|
|
304
304
|
data[handleName][segmentId] = [...segmentHandles[handleName]];
|
|
305
305
|
}
|
|
306
|
-
|
|
306
|
+
hasPendingEmission = true;
|
|
307
307
|
signalEmission();
|
|
308
308
|
},
|
|
309
309
|
};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import { parseCookiesFromHeader } from "./cookie-parse.js";
|
|
14
15
|
import type { CacheErrorCategory } from "../cache/cache-error.js";
|
|
15
16
|
import type { CookieOptions } from "../router/middleware.js";
|
|
16
17
|
import {
|
|
@@ -979,32 +980,10 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
|
|
|
979
980
|
return result;
|
|
980
981
|
}
|
|
981
982
|
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
export
|
|
986
|
-
cookieHeader: string | null,
|
|
987
|
-
): Record<string, string> {
|
|
988
|
-
if (!cookieHeader) return {};
|
|
989
|
-
|
|
990
|
-
const cookies: Record<string, string> = {};
|
|
991
|
-
const pairs = cookieHeader.split(";");
|
|
992
|
-
|
|
993
|
-
for (const pair of pairs) {
|
|
994
|
-
const [name, ...rest] = pair.trim().split("=");
|
|
995
|
-
if (name) {
|
|
996
|
-
const raw = rest.join("=");
|
|
997
|
-
try {
|
|
998
|
-
cookies[name] = decodeURIComponent(raw);
|
|
999
|
-
} catch {
|
|
1000
|
-
// Malformed percent-encoding: fall back to raw value
|
|
1001
|
-
cookies[name] = raw;
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
return cookies;
|
|
1007
|
-
}
|
|
983
|
+
// Re-exported for unit tests and the existing import path. The implementation
|
|
984
|
+
// lives in the dependency-free ./cookie-parse leaf so consumers (e.g. the host
|
|
985
|
+
// dispatcher) can share it without pulling this module's request-context graph.
|
|
986
|
+
export { parseCookiesFromHeader };
|
|
1008
987
|
|
|
1009
988
|
export function serializeCookieValue(
|
|
1010
989
|
name: string,
|