@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.
- 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 +14 -10
- 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/loader-cache.ts +13 -0
- 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
|
@@ -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,
|
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 {
|
|
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
|
|
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
|
|
247
|
-
|
|
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
|
}
|