@rangojs/router 0.0.0-experimental.131 → 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 (51) 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 +14 -10
  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/loader-cache.ts +13 -0
  36. package/src/router/segment-wrappers.ts +3 -0
  37. package/src/router/trie-matching.ts +74 -20
  38. package/src/router.ts +2 -2
  39. package/src/rsc/progressive-enhancement.ts +20 -0
  40. package/src/rsc/server-action.ts +124 -47
  41. package/src/search-params.ts +8 -6
  42. package/src/segment-system.tsx +7 -1
  43. package/src/server/cookie-parse.ts +32 -0
  44. package/src/server/handle-store.ts +14 -14
  45. package/src/server/request-context.ts +5 -26
  46. package/src/ssr/index.tsx +5 -4
  47. package/src/testing/render-handler.ts +11 -0
  48. package/src/vite/plugins/expose-id-utils.ts +77 -2
  49. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  50. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  51. package/src/vite/utils/prerender-utils.ts +1 -3
@@ -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,
package/src/ssr/index.tsx CHANGED
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
  import { renderSegments } from "../segment-system.js";
3
- import { filterSegmentOrder } from "../browser/react/filter-segment-order.js";
3
+ import {
4
+ filterSegmentOrder,
5
+ filterRouteSegmentIds,
6
+ } from "../browser/react/filter-segment-order.js";
4
7
  import { ThemeProvider } from "../theme/ThemeProvider.js";
5
8
  import { NonceContext } from "../browser/react/nonce-context.js";
6
9
  import { NavigationStoreContext } from "../browser/react/context.js";
@@ -166,9 +169,7 @@ function createSsrEventController(opts: {
166
169
  const handleState = {
167
170
  data: opts.handleData ?? {},
168
171
  segmentOrder: filterSegmentOrder(rawMatched),
169
- routeSegmentIds: rawMatched.filter(
170
- (id) => !id.includes(".@") && !/D\d+\./.test(id),
171
- ),
172
+ routeSegmentIds: filterRouteSegmentIds(rawMatched),
172
173
  };
173
174
  const state: DerivedNavigationState = {
174
175
  state: "idle",
@@ -49,6 +49,8 @@ import {
49
49
  } from "./flight.js";
50
50
  import type { SegmentCacheStore } from "../cache/types.js";
51
51
  import type { CacheProfile } from "../cache/profile-registry.js";
52
+ import type { ThemeConfig } from "../theme/types.js";
53
+ import { resolveThemeConfig } from "../theme/constants.js";
52
54
  import {
53
55
  deserializeFlight,
54
56
  makeClientManifest,
@@ -114,6 +116,13 @@ export interface RenderHandlerOptions<TEnv = any> {
114
116
  * `"use cache: profileName"` resolution once a `cacheStore` is wired.
115
117
  */
116
118
  cacheProfiles?: Record<string, CacheProfile>;
119
+ /**
120
+ * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
121
+ * or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert,
122
+ * mirroring an app with no theme configured. Pass one to exercise a handler
123
+ * that reads `ctx.theme` or writes the theme cookie via `ctx.setTheme(...)`.
124
+ */
125
+ theme?: ThemeConfig | true;
117
126
  }
118
127
 
119
128
  /** Result of {@link renderHandler}. */
@@ -225,6 +234,8 @@ export async function renderHandler<TEnv = any>(
225
234
  version: opts.stateCookie?.version,
226
235
  cacheStore: opts.cacheStore,
227
236
  cacheProfiles: opts.cacheProfiles,
237
+ themeConfig:
238
+ opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
228
239
  });
229
240
 
230
241
  const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
@@ -243,6 +243,81 @@ export function findStatementEnd(code: string, pos: number): number {
243
243
  return i;
244
244
  }
245
245
 
246
- export function escapeRegExp(input: string): string {
247
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
246
+ export { escapeRegExp } from "../../regex-escape.js";
247
+
248
+ /**
249
+ * Given an index pointing just past a `create*` callee identifier, return the
250
+ * index of the call's opening `(`, accounting for an optional generic type
251
+ * argument list `<...>` with arbitrarily nested `<>`. Returns -1 when the next
252
+ * non-trivia token is neither `<` nor `(` (i.e. not a call site).
253
+ *
254
+ * Replaces the single-level `<[^>]*>` regex assumption, which stopped at the
255
+ * first `>` and so never reached `(` for `createRouter<Config<Env>>(`.
256
+ *
257
+ * Whitespace, comments, and strings between the callee and `(` are skipped via
258
+ * skipStringOrComment. The `<...>` scan only balances angle brackets; it does
259
+ * not attempt to parse full TS type syntax (sufficient for locating the paren).
260
+ */
261
+ export function findCallParenAfterGenerics(
262
+ code: string,
263
+ afterCalleeIndex: number,
264
+ ): number {
265
+ let i = afterCalleeIndex;
266
+ // Skip leading whitespace/comments/strings before `<` or `(`.
267
+ while (i < code.length) {
268
+ const skipped = skipStringOrComment(code, i);
269
+ if (skipped > i) {
270
+ i = skipped;
271
+ continue;
272
+ }
273
+ if (/\s/.test(code[i])) {
274
+ i++;
275
+ continue;
276
+ }
277
+ break;
278
+ }
279
+
280
+ if (i >= code.length) return -1;
281
+
282
+ if (code[i] === "(") return i;
283
+
284
+ if (code[i] === "<") {
285
+ let depth = 0;
286
+ while (i < code.length) {
287
+ const skipped = skipStringOrComment(code, i);
288
+ if (skipped > i) {
289
+ i = skipped;
290
+ continue;
291
+ }
292
+ const ch = code[i];
293
+ if (ch === "<") {
294
+ depth++;
295
+ } else if (ch === ">") {
296
+ depth--;
297
+ if (depth === 0) {
298
+ i++;
299
+ break;
300
+ }
301
+ }
302
+ i++;
303
+ }
304
+ if (depth !== 0) return -1;
305
+ // After the balanced generic list, skip trivia and expect `(`.
306
+ while (i < code.length) {
307
+ const skipped = skipStringOrComment(code, i);
308
+ if (skipped > i) {
309
+ i = skipped;
310
+ continue;
311
+ }
312
+ if (/\s/.test(code[i])) {
313
+ i++;
314
+ continue;
315
+ }
316
+ break;
317
+ }
318
+ if (i < code.length && code[i] === "(") return i;
319
+ return -1;
320
+ }
321
+
322
+ return -1;
248
323
  }