@rangojs/router 0.0.0-experimental.57 → 0.0.0-experimental.57005a2b
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/README.md +76 -18
- package/dist/bin/rango.js +2 -1
- package/dist/vite/index.js +507 -192
- package/dist/vite/index.js.bak +5448 -0
- package/package.json +3 -3
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/__internal.ts +1 -1
- package/src/browser/navigation-bridge.ts +21 -2
- package/src/browser/navigation-client.ts +34 -6
- package/src/browser/partial-update.ts +14 -2
- package/src/browser/prefetch/cache.ts +16 -6
- package/src/browser/prefetch/fetch.ts +60 -4
- package/src/browser/react/Link.tsx +25 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/build/generate-manifest.ts +3 -6
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/client.tsx +84 -230
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +194 -32
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +46 -6
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/loader-resolution.ts +147 -19
- package/src/router/manifest.ts +12 -7
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/cache-lookup.ts +24 -7
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +82 -4
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +108 -8
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +11 -0
- package/src/router/segment-resolution/fresh.ts +59 -2
- package/src/router/segment-resolution/revalidation.ts +79 -6
- package/src/router.ts +13 -1
- package/src/rsc/handler.ts +468 -377
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/progressive-enhancement.ts +10 -2
- package/src/rsc/rsc-rendering.ts +5 -1
- package/src/rsc/server-action.ts +6 -0
- package/src/rsc/ssr-setup.ts +1 -1
- package/src/rsc/types.ts +1 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +40 -4
- package/src/server/handle-store.ts +19 -0
- package/src/server/request-context.ts +125 -3
- package/src/static-handler.ts +18 -6
- package/src/types/handler-context.ts +12 -2
- package/src/types/loader-types.ts +32 -4
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +47 -12
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +2 -1
- package/src/vite/router-discovery.ts +178 -37
- package/src/vite/utils/prerender-utils.ts +37 -5
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
* Content Negotiation Utilities
|
|
3
3
|
*
|
|
4
4
|
* Pure functions for HTTP Accept header parsing and response type matching.
|
|
5
|
-
* Used by
|
|
5
|
+
* Used by previewMatch and classifyRequest for content negotiation between
|
|
6
6
|
* RSC routes and response routes (JSON, text, image, stream, etc.).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { EntryData } from "../server/context.js";
|
|
10
|
+
import type { CollectedMiddleware } from "./middleware-types.js";
|
|
11
|
+
import { collectRouteMiddleware } from "./middleware.js";
|
|
12
|
+
import { loadManifest } from "./manifest.js";
|
|
13
|
+
import { traverseBack } from "./pattern-matching.js";
|
|
14
|
+
import type { RouteMatchResult } from "./pattern-matching.js";
|
|
15
|
+
import type { RouteSnapshot } from "./route-snapshot.js";
|
|
16
|
+
|
|
9
17
|
// Response type -> MIME type used for Accept header matching
|
|
10
18
|
export const RESPONSE_TYPE_MIME: Record<string, string> = {
|
|
11
19
|
json: "application/json",
|
|
@@ -114,3 +122,94 @@ export function pickNegotiateVariant(
|
|
|
114
122
|
// No match -- use first candidate as default
|
|
115
123
|
return candidates[0]!;
|
|
116
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Result of content negotiation for a route with negotiate variants.
|
|
128
|
+
*/
|
|
129
|
+
export interface NegotiationResult {
|
|
130
|
+
/** The winning response type */
|
|
131
|
+
responseType: string;
|
|
132
|
+
/** Handler function for the winning variant */
|
|
133
|
+
handler: Function;
|
|
134
|
+
/** Manifest entry for the winning variant (may differ from primary) */
|
|
135
|
+
manifestEntry: EntryData;
|
|
136
|
+
/** Route middleware for the winning variant */
|
|
137
|
+
routeMiddleware: CollectedMiddleware[];
|
|
138
|
+
/** Always true — negotiation occurred */
|
|
139
|
+
negotiated: true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Perform content negotiation for a route with negotiate variants.
|
|
144
|
+
*
|
|
145
|
+
* Returns a NegotiationResult when a response route wins negotiation.
|
|
146
|
+
* Returns null when RSC wins or no negotiation is needed.
|
|
147
|
+
*
|
|
148
|
+
* Shared by previewMatch and classifyRequest to avoid duplicating
|
|
149
|
+
* the candidate-building and variant-loading logic.
|
|
150
|
+
*/
|
|
151
|
+
export async function negotiateRoute(
|
|
152
|
+
request: Request,
|
|
153
|
+
pathname: string,
|
|
154
|
+
snapshot: RouteSnapshot,
|
|
155
|
+
): Promise<NegotiationResult | null> {
|
|
156
|
+
const { matched, manifestEntry, routeMiddleware, responseType } = snapshot;
|
|
157
|
+
if (!matched.negotiateVariants || matched.negotiateVariants.length === 0) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const acceptEntries = parseAcceptTypes(request.headers.get("accept") || "");
|
|
162
|
+
|
|
163
|
+
// Build candidate list preserving definition order.
|
|
164
|
+
const variants = matched.negotiateVariants;
|
|
165
|
+
let candidates: Array<{ routeKey: string; responseType: string }>;
|
|
166
|
+
if (responseType) {
|
|
167
|
+
candidates = [...variants, { routeKey: matched.routeKey, responseType }];
|
|
168
|
+
} else {
|
|
169
|
+
const rscCandidate = {
|
|
170
|
+
routeKey: matched.routeKey,
|
|
171
|
+
responseType: RSC_RESPONSE_TYPE,
|
|
172
|
+
};
|
|
173
|
+
candidates = matched.rscFirst
|
|
174
|
+
? [rscCandidate, ...variants]
|
|
175
|
+
: [...variants, rscCandidate];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const variant = pickNegotiateVariant(acceptEntries, candidates);
|
|
179
|
+
|
|
180
|
+
// RSC won negotiation
|
|
181
|
+
if (variant.responseType === RSC_RESPONSE_TYPE) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Primary response-type won — use existing manifest entry and middleware
|
|
186
|
+
if (responseType && variant.routeKey === matched.routeKey) {
|
|
187
|
+
return {
|
|
188
|
+
responseType,
|
|
189
|
+
handler: manifestEntry.handler as Function,
|
|
190
|
+
manifestEntry,
|
|
191
|
+
routeMiddleware,
|
|
192
|
+
negotiated: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Different variant won — load its manifest entry
|
|
197
|
+
const negotiateEntry = await loadManifest(
|
|
198
|
+
matched.entry,
|
|
199
|
+
variant.routeKey,
|
|
200
|
+
pathname,
|
|
201
|
+
undefined,
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
const variantMiddleware = collectRouteMiddleware(
|
|
205
|
+
traverseBack(negotiateEntry),
|
|
206
|
+
matched.params,
|
|
207
|
+
);
|
|
208
|
+
return {
|
|
209
|
+
responseType: variant.responseType,
|
|
210
|
+
handler: negotiateEntry.handler as Function,
|
|
211
|
+
manifestEntry: negotiateEntry,
|
|
212
|
+
routeMiddleware: variantMiddleware,
|
|
213
|
+
negotiated: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -114,9 +114,9 @@ function createPrerenderPassthroughFn(
|
|
|
114
114
|
}
|
|
115
115
|
if (!isPassthroughRoute) {
|
|
116
116
|
throw new Error(
|
|
117
|
-
"ctx.passthrough() is only available on routes
|
|
118
|
-
"
|
|
119
|
-
"
|
|
117
|
+
"ctx.passthrough() is only available on routes wrapped with " +
|
|
118
|
+
"Passthrough(). Remove the passthrough() call or wrap the " +
|
|
119
|
+
"Prerender definition with Passthrough(prerenderDef, liveHandler).",
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
return PRERENDER_PASSTHROUGH;
|
|
@@ -166,9 +166,27 @@ export function createReverseFunction(
|
|
|
166
166
|
: hrefParams;
|
|
167
167
|
|
|
168
168
|
// Substitute params (strip constraint and optional syntax: :param(a|b)? -> value)
|
|
169
|
+
// Optional params (:param?) are omitted when not provided
|
|
169
170
|
if (effectiveParams) {
|
|
171
|
+
let hadOmittedOptional = false;
|
|
172
|
+
// First pass: optional params (trailing ?)
|
|
170
173
|
result = result.replace(
|
|
171
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))
|
|
174
|
+
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
175
|
+
(_, key) => {
|
|
176
|
+
const value = effectiveParams[key];
|
|
177
|
+
// Empty string is treated as omitted — the trie matcher fills
|
|
178
|
+
// unmatched optional params with "" (not undefined), so reverse
|
|
179
|
+
// must collapse those segments instead of leaving empty slots.
|
|
180
|
+
if (value === undefined || value === "") {
|
|
181
|
+
hadOmittedOptional = true;
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
return encodeURIComponent(value);
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
// Second pass: required params (no trailing ?)
|
|
188
|
+
result = result.replace(
|
|
189
|
+
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
172
190
|
(_, key) => {
|
|
173
191
|
const value = effectiveParams[key];
|
|
174
192
|
if (value === undefined) {
|
|
@@ -177,6 +195,13 @@ export function createReverseFunction(
|
|
|
177
195
|
return encodeURIComponent(value);
|
|
178
196
|
},
|
|
179
197
|
);
|
|
198
|
+
// Clean up slashes only when an optional param was actually omitted,
|
|
199
|
+
// so intentional trailing-slash patterns like "/blog/" are preserved.
|
|
200
|
+
if (hadOmittedOptional) {
|
|
201
|
+
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
202
|
+
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
203
|
+
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
// Append search params as query string
|
|
@@ -250,6 +275,7 @@ export function createHandlerContext<TEnv>(
|
|
|
250
275
|
ctx = {
|
|
251
276
|
params,
|
|
252
277
|
build: false,
|
|
278
|
+
dev: false,
|
|
253
279
|
request,
|
|
254
280
|
searchParams,
|
|
255
281
|
search: searchSchema ? resolvedSearchParams : {},
|
|
@@ -329,6 +355,8 @@ export function createPrerenderContext<TEnv>(
|
|
|
329
355
|
routeName?: string,
|
|
330
356
|
buildVars?: Record<string, any>,
|
|
331
357
|
isPassthroughRoute?: boolean,
|
|
358
|
+
buildEnv?: TEnv,
|
|
359
|
+
devMode?: boolean,
|
|
332
360
|
): InternalHandlerContext<any, TEnv> {
|
|
333
361
|
const syntheticUrl = new URL(`http://prerender${pathname}`);
|
|
334
362
|
const variables = buildVars ?? {};
|
|
@@ -343,6 +371,7 @@ export function createPrerenderContext<TEnv>(
|
|
|
343
371
|
return {
|
|
344
372
|
params,
|
|
345
373
|
build: true,
|
|
374
|
+
dev: devMode ?? false,
|
|
346
375
|
get request(): Request {
|
|
347
376
|
return throwUnavailable("request");
|
|
348
377
|
},
|
|
@@ -352,7 +381,11 @@ export function createPrerenderContext<TEnv>(
|
|
|
352
381
|
url: syntheticUrl,
|
|
353
382
|
originalUrl: syntheticUrl,
|
|
354
383
|
get env(): TEnv {
|
|
355
|
-
|
|
384
|
+
if (buildEnv !== undefined) return buildEnv;
|
|
385
|
+
throw new Error(
|
|
386
|
+
"ctx.env is not available during pre-rendering. " +
|
|
387
|
+
"Configure buildEnv in your rango() plugin options to enable build-time env access.",
|
|
388
|
+
);
|
|
356
389
|
},
|
|
357
390
|
_variables: variables,
|
|
358
391
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
@@ -400,6 +433,8 @@ export function createPrerenderContext<TEnv>(
|
|
|
400
433
|
export function createStaticContext<TEnv>(
|
|
401
434
|
routeMap: Record<string, string>,
|
|
402
435
|
routeName?: string,
|
|
436
|
+
buildEnv?: TEnv,
|
|
437
|
+
devMode?: boolean,
|
|
403
438
|
): InternalHandlerContext<any, TEnv> {
|
|
404
439
|
const variables: Record<string, any> = {};
|
|
405
440
|
|
|
@@ -415,6 +450,7 @@ export function createStaticContext<TEnv>(
|
|
|
415
450
|
return throwUnavailable("params");
|
|
416
451
|
},
|
|
417
452
|
build: true,
|
|
453
|
+
dev: devMode ?? false,
|
|
418
454
|
get request(): Request {
|
|
419
455
|
return throwUnavailable("request");
|
|
420
456
|
},
|
|
@@ -434,7 +470,11 @@ export function createStaticContext<TEnv>(
|
|
|
434
470
|
return throwUnavailable("originalUrl");
|
|
435
471
|
},
|
|
436
472
|
get env(): TEnv {
|
|
437
|
-
|
|
473
|
+
if (buildEnv !== undefined) return buildEnv;
|
|
474
|
+
throw new Error(
|
|
475
|
+
"ctx.env is not available in Static() handlers. " +
|
|
476
|
+
"Configure buildEnv in your rango() plugin options to enable build-time env access.",
|
|
477
|
+
);
|
|
438
478
|
},
|
|
439
479
|
_variables: variables,
|
|
440
480
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
@@ -125,9 +125,8 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
125
125
|
// Merge captured counters from include() to maintain consistent
|
|
126
126
|
// shortCode indices with sibling entries from pattern extraction
|
|
127
127
|
const lazyCounters: Record<string, number> = {};
|
|
128
|
-
if (lazyContext
|
|
129
|
-
const
|
|
130
|
-
for (const [key, value] of Object.entries(captured)) {
|
|
128
|
+
if (lazyContext?.counters) {
|
|
129
|
+
for (const [key, value] of Object.entries(lazyContext.counters)) {
|
|
131
130
|
lazyCounters[key] = value;
|
|
132
131
|
}
|
|
133
132
|
}
|
|
@@ -141,8 +140,9 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
141
140
|
namespace: "lazy",
|
|
142
141
|
parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
|
|
143
142
|
counters: lazyCounters,
|
|
144
|
-
cacheProfiles:
|
|
145
|
-
rootScoped:
|
|
143
|
+
cacheProfiles: lazyContext?.cacheProfiles,
|
|
144
|
+
rootScoped: lazyContext?.rootScoped,
|
|
145
|
+
includeScope: lazyContext?.includeScope,
|
|
146
146
|
},
|
|
147
147
|
() => {
|
|
148
148
|
// Run the lazy patterns handler with the original context prefixes
|
|
@@ -20,10 +20,11 @@ import type {
|
|
|
20
20
|
ErrorInfo,
|
|
21
21
|
} from "../types";
|
|
22
22
|
import type { LoaderRevalidationResult, ActionContext } from "./types";
|
|
23
|
-
import { isHandle, type Handle } from "../handle.js";
|
|
24
|
-
import
|
|
23
|
+
import { isHandle, collectHandleData, type Handle } from "../handle.js";
|
|
24
|
+
import { buildHandleSnapshot } from "../server/handle-store.js";
|
|
25
25
|
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
26
26
|
import { _getRequestContext } from "../server/request-context.js";
|
|
27
|
+
import { isInsideLoaderScope } from "../server/context.js";
|
|
27
28
|
import { debugLog } from "./logging.js";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -243,6 +244,15 @@ function createLoaderExecutor<TEnv>(
|
|
|
243
244
|
|
|
244
245
|
const currentLoaderId = loader.$$id;
|
|
245
246
|
const variables = (ctx as InternalHandlerContext<any, TEnv>)._variables;
|
|
247
|
+
|
|
248
|
+
// Capture whether this loader is being started from a DSL loader scope
|
|
249
|
+
// (runInsideLoaderScope in fresh.ts). Handler-invoked loaders are NOT
|
|
250
|
+
// inside loader scope. This determines whether rendered() is allowed.
|
|
251
|
+
const isDslLoader = isInsideLoaderScope();
|
|
252
|
+
|
|
253
|
+
let renderedResolved = false;
|
|
254
|
+
let renderedPromise: Promise<void> | null = null;
|
|
255
|
+
|
|
246
256
|
// Loader functions are always fresh (never cached), so they get an
|
|
247
257
|
// unguarded get that bypasses non-cacheable read guards. This applies
|
|
248
258
|
// to ALL loaders — DSL and handler-called — because the loader
|
|
@@ -259,14 +269,84 @@ function createLoaderExecutor<TEnv>(
|
|
|
259
269
|
env: ctx.env,
|
|
260
270
|
get: ((keyOrVar: any) =>
|
|
261
271
|
contextGet(variables, keyOrVar)) as typeof ctx.get,
|
|
262
|
-
use: <
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
272
|
+
use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
273
|
+
if (isHandle(item)) {
|
|
274
|
+
if (!renderedResolved) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`ctx.use(handle) in a loader requires "await ctx.rendered()" first. ` +
|
|
277
|
+
`Handle "${item.$$id}" cannot be read until the render tree has settled.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
281
|
+
if (!reqCtx) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`ctx.use(handle) failed: request context not available.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
|
|
287
|
+
const snapshot =
|
|
288
|
+
reqCtx._renderBarrierHandleSnapshot ??
|
|
289
|
+
buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
|
|
290
|
+
return collectHandleData(item, snapshot, segmentOrder);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Loader case
|
|
294
|
+
return useLoader(item as LoaderDefinition<any, any>, currentLoaderId);
|
|
295
|
+
}) as LoaderContext["use"],
|
|
267
296
|
method: "GET",
|
|
268
297
|
body: undefined,
|
|
269
298
|
reverse: ctx.reverse as LoaderContext["reverse"],
|
|
299
|
+
rendered: (): Promise<void> => {
|
|
300
|
+
// Guard: only DSL loaders may use rendered()
|
|
301
|
+
if (!isDslLoader) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
|
|
304
|
+
`Handler-invoked loaders (ctx.use(Loader) inside a handler) cannot use rendered().`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Guard: reject streaming trees
|
|
309
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
310
|
+
if (reqCtx?._treeHasStreaming) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`ctx.rendered() is not supported when the matched route tree uses loading(). ` +
|
|
313
|
+
`Streaming handlers may not have settled when rendered() resolves. ` +
|
|
314
|
+
`Remove loading() from the route tree or restructure to avoid rendered().`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (renderedPromise) return renderedPromise;
|
|
319
|
+
|
|
320
|
+
if (!reqCtx) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`ctx.rendered() failed: request context not available.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Bidirectional deadlock check: if a handler already started
|
|
327
|
+
// awaiting this loader, calling rendered() would deadlock.
|
|
328
|
+
if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
|
|
331
|
+
`is already awaiting this loader via ctx.use(). The handler blocks ` +
|
|
332
|
+
`segment resolution, which blocks the barrier, which blocks this loader. ` +
|
|
333
|
+
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Register this loader as waiting for the barrier so that
|
|
338
|
+
// setupLoaderAccess can detect deadlocks when a handler
|
|
339
|
+
// tries to await the same loader via ctx.use().
|
|
340
|
+
if (!reqCtx._renderBarrierWaiters) {
|
|
341
|
+
reqCtx._renderBarrierWaiters = new Set();
|
|
342
|
+
}
|
|
343
|
+
reqCtx._renderBarrierWaiters.add(currentLoaderId);
|
|
344
|
+
|
|
345
|
+
renderedPromise = reqCtx._renderBarrier.then(() => {
|
|
346
|
+
renderedResolved = true;
|
|
347
|
+
});
|
|
348
|
+
return renderedPromise;
|
|
349
|
+
},
|
|
270
350
|
};
|
|
271
351
|
|
|
272
352
|
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
@@ -297,15 +377,22 @@ export function setupLoaderAccess<TEnv>(
|
|
|
297
377
|
ctx: HandlerContext<any, TEnv>,
|
|
298
378
|
loaderPromises: Map<string, Promise<any>>,
|
|
299
379
|
): void {
|
|
300
|
-
// Eagerly capture the HandleStore at setup time
|
|
301
|
-
// In workerd/Cloudflare, dynamic imports and
|
|
302
|
-
// can disrupt AsyncLocalStorage, causing
|
|
303
|
-
// undefined when handlers later call
|
|
304
|
-
//
|
|
305
|
-
const
|
|
380
|
+
// Eagerly capture the request context and HandleStore at setup time
|
|
381
|
+
// (before pipeline async ops). In workerd/Cloudflare, dynamic imports and
|
|
382
|
+
// fetch() in the match pipeline can disrupt AsyncLocalStorage, causing
|
|
383
|
+
// getRequestContext() to return undefined when handlers later call
|
|
384
|
+
// ctx.use(handle). Capturing early ensures references survive ALS disruption.
|
|
385
|
+
const reqCtxRef = _getRequestContext();
|
|
386
|
+
const handleStoreRef = reqCtxRef?._handleStore;
|
|
306
387
|
|
|
307
388
|
const useLoader = createLoaderExecutor(ctx, loaderPromises);
|
|
308
389
|
|
|
390
|
+
// Track whether we're inside a handle push callback. Loaders started
|
|
391
|
+
// from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
|
|
392
|
+
// block segment resolution, so they must not be registered as handler
|
|
393
|
+
// dependencies for deadlock detection.
|
|
394
|
+
let insideHandlePush = false;
|
|
395
|
+
|
|
309
396
|
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
310
397
|
if (isHandle(item)) {
|
|
311
398
|
const handle = item;
|
|
@@ -325,16 +412,57 @@ export function setupLoaderAccess<TEnv>(
|
|
|
325
412
|
) => {
|
|
326
413
|
if (!store) return;
|
|
327
414
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
415
|
+
if (typeof dataOrFn === "function") {
|
|
416
|
+
// Mark scope so ctx.use(loader) calls inside the callback
|
|
417
|
+
// are not registered as handler-to-loader deps.
|
|
418
|
+
insideHandlePush = true;
|
|
419
|
+
try {
|
|
420
|
+
const result = (dataOrFn as () => Promise<unknown>)();
|
|
421
|
+
store.push(handle.$$id, segmentId, result);
|
|
422
|
+
} finally {
|
|
423
|
+
insideHandlePush = false;
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
332
427
|
|
|
333
|
-
store.push(handle.$$id, segmentId,
|
|
428
|
+
store.push(handle.$$id, segmentId, dataOrFn);
|
|
334
429
|
};
|
|
335
430
|
}
|
|
336
431
|
|
|
337
|
-
|
|
432
|
+
// Deadlock guard and handler-to-loader dependency tracking.
|
|
433
|
+
// Skip when inside a DSL loader scope (resolveLoaderData also calls
|
|
434
|
+
// ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
|
|
435
|
+
// inside a handle push callback (push callbacks don't block segment
|
|
436
|
+
// resolution so they can't cause rendered() deadlocks).
|
|
437
|
+
const loader = item as LoaderDefinition<any, any>;
|
|
438
|
+
if (!isInsideLoaderScope() && !insideHandlePush) {
|
|
439
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
440
|
+
if (reqCtx) {
|
|
441
|
+
// Direction 1: handler awaits loader that already called rendered()
|
|
442
|
+
if (
|
|
443
|
+
loaderPromises.has(loader.$$id) &&
|
|
444
|
+
reqCtx._renderBarrierWaiters?.has(loader.$$id)
|
|
445
|
+
) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`Deadlock: handler is awaiting loader "${loader.$$id}" which called ctx.rendered(). ` +
|
|
448
|
+
`The loader is waiting for segment resolution, but the handler blocks resolution. ` +
|
|
449
|
+
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
// Direction 2: track dep so rendered() can detect the deadlock
|
|
453
|
+
// if the loader calls it later. Skip when the barrier has already
|
|
454
|
+
// resolved — no deadlock is possible (rendered() resolves immediately).
|
|
455
|
+
// _renderBarrierSegmentOrder is undefined before resolution, string[]
|
|
456
|
+
// after. This also prevents false positives from handle push callbacks
|
|
457
|
+
// that resume after their first await (post-barrier-resolution).
|
|
458
|
+
if (reqCtx._renderBarrierSegmentOrder === undefined) {
|
|
459
|
+
if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
|
|
460
|
+
reqCtx._handlerLoaderDeps.add(loader.$$id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return useLoader(loader, null);
|
|
338
466
|
}) as typeof ctx.use;
|
|
339
467
|
}
|
|
340
468
|
|
package/src/router/manifest.ts
CHANGED
|
@@ -126,9 +126,8 @@ export async function loadManifest(
|
|
|
126
126
|
// were created during pattern extraction. This prevents shortCode
|
|
127
127
|
// collisions between lazy and non-lazy entries under the same parent
|
|
128
128
|
// (e.g., ArticlesLayout and BlogLayout both under NavLayout).
|
|
129
|
-
if (lazyContext
|
|
130
|
-
const
|
|
131
|
-
for (const [key, value] of Object.entries(captured)) {
|
|
129
|
+
if (lazyContext?.counters) {
|
|
130
|
+
for (const [key, value] of Object.entries(lazyContext.counters)) {
|
|
132
131
|
Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
|
|
133
132
|
}
|
|
134
133
|
}
|
|
@@ -136,8 +135,7 @@ export async function loadManifest(
|
|
|
136
135
|
// Propagate cache profiles for DSL-time cache("profileName") resolution.
|
|
137
136
|
// Non-lazy entries carry profiles directly; lazy entries carry them
|
|
138
137
|
// in the captured lazyContext from include() time.
|
|
139
|
-
const entryProfiles =
|
|
140
|
-
entry.cacheProfiles ?? (lazyContext as any)?.cacheProfiles;
|
|
138
|
+
const entryProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
|
|
141
139
|
if (entryProfiles) {
|
|
142
140
|
Store.cacheProfiles = entryProfiles;
|
|
143
141
|
}
|
|
@@ -145,10 +143,17 @@ export async function loadManifest(
|
|
|
145
143
|
// Propagate rootScoped from lazyContext so that routes inside
|
|
146
144
|
// nested { name: "sub" } under { name: "" } keep inherited root scope
|
|
147
145
|
// when the manifest is rebuilt on each request.
|
|
148
|
-
if (lazyContext
|
|
149
|
-
Store.rootScoped =
|
|
146
|
+
if (lazyContext?.rootScoped !== undefined) {
|
|
147
|
+
Store.rootScoped = lazyContext.rootScoped;
|
|
150
148
|
}
|
|
151
149
|
|
|
150
|
+
// Propagate includeScope from lazyContext so that direct-descendant
|
|
151
|
+
// shortCodes of this include use the correct scoped counter namespace
|
|
152
|
+
// on every manifest rebuild. Always write (including clearing to
|
|
153
|
+
// undefined) so a prior lazy build's scope cannot leak into a later
|
|
154
|
+
// non-lazy build on the same ALS-backed Store.
|
|
155
|
+
Store.includeScope = lazyContext?.includeScope;
|
|
156
|
+
|
|
152
157
|
const handlerExecStart = performance.now();
|
|
153
158
|
const useItems = await getContext().runWithStore(
|
|
154
159
|
Store,
|