@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.
Files changed (54) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +56 -14
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/fresh.ts +8 -9
  36. package/src/router/segment-resolution/helpers.ts +11 -10
  37. package/src/router/segment-resolution/loader-cache.ts +13 -0
  38. package/src/router/segment-resolution/revalidation.ts +4 -4
  39. package/src/router/segment-wrappers.ts +3 -0
  40. package/src/router/trie-matching.ts +74 -20
  41. package/src/router.ts +2 -2
  42. package/src/rsc/progressive-enhancement.ts +20 -0
  43. package/src/rsc/server-action.ts +124 -47
  44. package/src/search-params.ts +8 -6
  45. package/src/segment-system.tsx +7 -1
  46. package/src/server/cookie-parse.ts +32 -0
  47. package/src/server/handle-store.ts +14 -14
  48. package/src/server/request-context.ts +5 -26
  49. package/src/ssr/index.tsx +5 -4
  50. package/src/testing/render-handler.ts +11 -0
  51. package/src/vite/plugins/expose-id-utils.ts +77 -2
  52. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  53. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  54. 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 false lets walkTrie unwind to a
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
- ): boolean {
128
- if (!leaf.cv) return true;
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 && leafConstraintsPass(node.r, paramValues, undefined)) {
158
- return { leaf: node.r, paramValues: [...paramValues] };
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 && leafConstraintsPass(node.w, paramValues, "")) {
167
- return { leaf: node.w, paramValues: [...paramValues], wildcardValue: "" };
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
- if (leafConstraintsPass(node.w, paramValues, rest)) {
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
- const params: Record<string, string> = {};
242
- if (leaf.pa) {
243
- for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
244
- params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
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
- if (wildcardValue !== undefined && "pn" in leaf) {
249
- params[(leaf as TrieLeaf & { pn: string }).pn] =
250
- safeDecodeURIComponent(wildcardValue);
251
- }
301
+ if (wildcardValue !== undefined && "pn" in leaf) {
302
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
303
+ safeDecodeURIComponent(wildcardValue);
304
+ }
252
305
 
253
- if (!constraintsSatisfied(leaf, params)) {
254
- return null;
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
- parsePattern,
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 = parsePattern(fullPattern);
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
  };
@@ -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/error-boundary, the handler
38
- * passes this to route middleware → revalidateAfterAction.
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: error };
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
- setRequestContextParams(errorResult.params, errorResult.routeName);
183
-
184
- const payload: RscPayload = {
185
- metadata: {
186
- pathname: url.pathname,
187
- // routerId exposed for the frontend (current app identity); see
188
- // rsc-rendering.ts partial branch.
189
- routerId: ctx.router.id,
190
- segments: errorResult.segments,
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
- onError: (error: unknown) => {
210
- ctx.callOnError(error, "rendering", { request, url, env });
211
- },
212
- });
213
-
214
- return createResponseWithMergedHeaders(rscStream, {
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 { returnValue, actionStatus, temporaryReferences, actionContext } =
294
- continuation;
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 },
@@ -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
- const parts: string[] = [];
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
- parts.push(
210
- `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
211
- );
213
+ pairs.push([key, String(value)]);
212
214
  }
213
- return parts.join("&");
215
+ return encodeKV(pairs);
214
216
  }
@@ -30,9 +30,15 @@ function isRenderableLoading(loading: ReactNode): boolean {
30
30
  return loading !== undefined && loading !== null && loading !== false;
31
31
  }
32
32
 
33
- function restoreParallelLoaderMarkers(
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
- // Queue for pending emissions and resolver for waiting consumer
178
- let pendingEmissions: HandleData[] = [];
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 (pendingEmissions.length > 0 || completed) {
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
- // Queue a snapshot for emission
242
- pendingEmissions.push(cloneHandleData(data));
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
- pendingEmissions = [];
264
- const snapshot = cloneHandleData(data);
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 (pendingEmissions.length > 0) {
272
- const latest = pendingEmissions[pendingEmissions.length - 1];
273
- pendingEmissions = [];
274
- yield latest;
272
+ if (hasPendingEmission) {
273
+ hasPendingEmission = false;
274
+ yield cloneHandleData(data);
275
275
  }
276
276
  }
277
277
 
278
- if (pendingEmissions.length > 0) {
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
- pendingEmissions.push(cloneHandleData(data));
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
- // Exported for unit tests; the canonical cookie parse/serialize lives here
983
- // (a duplicate copy in middleware-cookies.ts was removed). Not part of the
984
- // public export surface.
985
- export function parseCookiesFromHeader(
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,