@rangojs/router 0.0.0-experimental.64 → 0.0.0-experimental.65
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/vite/index.js +55 -48
- package/package.json +15 -14
- package/src/browser/react/use-handle.ts +9 -58
- package/src/handle.ts +40 -0
- package/src/router/loader-resolution.ts +113 -8
- package/src/router/match-middleware/cache-lookup.ts +16 -0
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/prerender-match.ts +4 -0
- package/src/router/segment-resolution/fresh.ts +12 -2
- package/src/router/segment-resolution/revalidation.ts +21 -2
- package/src/server/context.ts +9 -0
- package/src/server/request-context.ts +89 -2
- package/src/types/loader-types.ts +32 -4
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +46 -40
- package/src/vite/router-discovery.ts +25 -3
package/dist/vite/index.js
CHANGED
|
@@ -910,9 +910,7 @@ function generateWholeFileStubs(cfg, bindings, code, filePath, isBuild) {
|
|
|
910
910
|
});
|
|
911
911
|
return { code: stubs.join("\n") + "\n", map: null };
|
|
912
912
|
}
|
|
913
|
-
function
|
|
914
|
-
if (bindings.length === 0) return null;
|
|
915
|
-
const s = new MagicString2(code);
|
|
913
|
+
function stubHandlerExprs(cfg, bindings, s, filePath, isBuild) {
|
|
916
914
|
let hasChanges = false;
|
|
917
915
|
for (const binding of bindings) {
|
|
918
916
|
const exportName = binding.exportNames[0];
|
|
@@ -924,15 +922,7 @@ function generateExprStubs(cfg, bindings, code, filePath, sourceId, isBuild) {
|
|
|
924
922
|
);
|
|
925
923
|
hasChanges = true;
|
|
926
924
|
}
|
|
927
|
-
|
|
928
|
-
return {
|
|
929
|
-
code: s.toString(),
|
|
930
|
-
map: s.generateMap({
|
|
931
|
-
source: sourceId,
|
|
932
|
-
includeContent: true,
|
|
933
|
-
hires: "boundary"
|
|
934
|
-
})
|
|
935
|
-
};
|
|
925
|
+
return hasChanges;
|
|
936
926
|
}
|
|
937
927
|
function transformHandlerIds(cfg, bindings, s, filePath, isBuild) {
|
|
938
928
|
let hasChanges = false;
|
|
@@ -1269,15 +1259,6 @@ ${lazyImports.join(",\n")}
|
|
|
1269
1259
|
isBuild
|
|
1270
1260
|
);
|
|
1271
1261
|
if (wholeFile) return wholeFile;
|
|
1272
|
-
const exprStubs = generateExprStubs(
|
|
1273
|
-
PRERENDER_CONFIG,
|
|
1274
|
-
bindings,
|
|
1275
|
-
code,
|
|
1276
|
-
filePath,
|
|
1277
|
-
id,
|
|
1278
|
-
isBuild
|
|
1279
|
-
);
|
|
1280
|
-
if (exprStubs) return exprStubs;
|
|
1281
1262
|
}
|
|
1282
1263
|
if (hasPrerenderHandlerCode && isRscEnv && isBuild) {
|
|
1283
1264
|
const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
|
|
@@ -1329,15 +1310,6 @@ ${lazyImports.join(",\n")}
|
|
|
1329
1310
|
isBuild
|
|
1330
1311
|
);
|
|
1331
1312
|
if (wholeFile) return wholeFile;
|
|
1332
|
-
const exprStubs = generateExprStubs(
|
|
1333
|
-
STATIC_CONFIG,
|
|
1334
|
-
bindings,
|
|
1335
|
-
code,
|
|
1336
|
-
filePath,
|
|
1337
|
-
id,
|
|
1338
|
-
isBuild
|
|
1339
|
-
);
|
|
1340
|
-
if (exprStubs) return exprStubs;
|
|
1341
1313
|
}
|
|
1342
1314
|
if (hasStaticHandlerCode && isRscEnv && isBuild) {
|
|
1343
1315
|
const fnNames = getFnNames(STATIC_CONFIG.fnName);
|
|
@@ -1372,25 +1344,41 @@ ${lazyImports.join(",\n")}
|
|
|
1372
1344
|
isBuild
|
|
1373
1345
|
) || changed;
|
|
1374
1346
|
}
|
|
1375
|
-
if (hasPrerenderHandlerCode
|
|
1347
|
+
if (hasPrerenderHandlerCode) {
|
|
1376
1348
|
const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1349
|
+
const bindings = getBindings(code, fnNames);
|
|
1350
|
+
if (isRscEnv) {
|
|
1351
|
+
changed = transformHandlerIds(
|
|
1352
|
+
PRERENDER_CONFIG,
|
|
1353
|
+
bindings,
|
|
1354
|
+
s,
|
|
1355
|
+
filePath,
|
|
1356
|
+
isBuild
|
|
1357
|
+
) || changed;
|
|
1358
|
+
} else {
|
|
1359
|
+
changed = stubHandlerExprs(
|
|
1360
|
+
PRERENDER_CONFIG,
|
|
1361
|
+
bindings,
|
|
1362
|
+
s,
|
|
1363
|
+
filePath,
|
|
1364
|
+
isBuild
|
|
1365
|
+
) || changed;
|
|
1366
|
+
}
|
|
1384
1367
|
}
|
|
1385
|
-
if (hasStaticHandlerCode
|
|
1368
|
+
if (hasStaticHandlerCode) {
|
|
1386
1369
|
const fnNames = getFnNames(STATIC_CONFIG.fnName);
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1370
|
+
const bindings = getBindings(code, fnNames);
|
|
1371
|
+
if (isRscEnv) {
|
|
1372
|
+
changed = transformHandlerIds(
|
|
1373
|
+
STATIC_CONFIG,
|
|
1374
|
+
bindings,
|
|
1375
|
+
s,
|
|
1376
|
+
filePath,
|
|
1377
|
+
isBuild
|
|
1378
|
+
) || changed;
|
|
1379
|
+
} else {
|
|
1380
|
+
changed = stubHandlerExprs(STATIC_CONFIG, bindings, s, filePath, isBuild) || changed;
|
|
1381
|
+
}
|
|
1394
1382
|
}
|
|
1395
1383
|
if (!changed) return;
|
|
1396
1384
|
return {
|
|
@@ -1745,7 +1733,7 @@ import { resolve } from "node:path";
|
|
|
1745
1733
|
// package.json
|
|
1746
1734
|
var package_default = {
|
|
1747
1735
|
name: "@rangojs/router",
|
|
1748
|
-
version: "0.0.0-experimental.
|
|
1736
|
+
version: "0.0.0-experimental.65",
|
|
1749
1737
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1750
1738
|
keywords: [
|
|
1751
1739
|
"react",
|
|
@@ -4785,7 +4773,26 @@ ${err.stack}`
|
|
|
4785
4773
|
res.end("Missing pathname");
|
|
4786
4774
|
return;
|
|
4787
4775
|
}
|
|
4788
|
-
|
|
4776
|
+
const rscEnv = server.environments?.rsc;
|
|
4777
|
+
let registry = null;
|
|
4778
|
+
if (rscEnv?.runner && s.resolvedEntryPath) {
|
|
4779
|
+
try {
|
|
4780
|
+
await rscEnv.runner.import(s.resolvedEntryPath);
|
|
4781
|
+
const serverMod = await rscEnv.runner.import(
|
|
4782
|
+
"@rangojs/router/server"
|
|
4783
|
+
);
|
|
4784
|
+
registry = serverMod.RouterRegistry ?? null;
|
|
4785
|
+
} catch (err) {
|
|
4786
|
+
console.warn(
|
|
4787
|
+
`[rsc-router] Dev prerender module refresh failed: ${err.message}`
|
|
4788
|
+
);
|
|
4789
|
+
res.statusCode = 500;
|
|
4790
|
+
res.end(`Prerender handler error: ${err.message}`);
|
|
4791
|
+
return;
|
|
4792
|
+
}
|
|
4793
|
+
} else {
|
|
4794
|
+
registry = mainRegistry;
|
|
4795
|
+
}
|
|
4789
4796
|
if (!registry) {
|
|
4790
4797
|
if (!prerenderNodeRegistry) {
|
|
4791
4798
|
await getOrCreateTempServer();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.65",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -132,6 +132,15 @@
|
|
|
132
132
|
"access": "public",
|
|
133
133
|
"tag": "experimental"
|
|
134
134
|
},
|
|
135
|
+
"scripts": {
|
|
136
|
+
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
137
|
+
"prepublishOnly": "pnpm build",
|
|
138
|
+
"typecheck": "tsc --noEmit",
|
|
139
|
+
"test": "playwright test",
|
|
140
|
+
"test:ui": "playwright test --ui",
|
|
141
|
+
"test:unit": "vitest run",
|
|
142
|
+
"test:unit:watch": "vitest"
|
|
143
|
+
},
|
|
135
144
|
"dependencies": {
|
|
136
145
|
"@vitejs/plugin-rsc": "^0.5.19",
|
|
137
146
|
"magic-string": "^0.30.17",
|
|
@@ -141,12 +150,12 @@
|
|
|
141
150
|
"devDependencies": {
|
|
142
151
|
"@playwright/test": "^1.49.1",
|
|
143
152
|
"@types/node": "^24.10.1",
|
|
144
|
-
"@types/react": "
|
|
145
|
-
"@types/react-dom": "
|
|
153
|
+
"@types/react": "catalog:",
|
|
154
|
+
"@types/react-dom": "catalog:",
|
|
146
155
|
"esbuild": "^0.27.0",
|
|
147
156
|
"jiti": "^2.6.1",
|
|
148
|
-
"react": "
|
|
149
|
-
"react-dom": "
|
|
157
|
+
"react": "catalog:",
|
|
158
|
+
"react-dom": "catalog:",
|
|
150
159
|
"tinyexec": "^0.3.2",
|
|
151
160
|
"typescript": "^5.3.0",
|
|
152
161
|
"vitest": "^4.0.0"
|
|
@@ -164,13 +173,5 @@
|
|
|
164
173
|
"vite": {
|
|
165
174
|
"optional": true
|
|
166
175
|
}
|
|
167
|
-
},
|
|
168
|
-
"scripts": {
|
|
169
|
-
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
170
|
-
"typecheck": "tsc --noEmit",
|
|
171
|
-
"test": "playwright test",
|
|
172
|
-
"test:ui": "playwright test --ui",
|
|
173
|
-
"test:unit": "vitest run",
|
|
174
|
-
"test:unit:watch": "vitest"
|
|
175
176
|
}
|
|
176
|
-
}
|
|
177
|
+
}
|
|
@@ -9,64 +9,11 @@ import {
|
|
|
9
9
|
startTransition,
|
|
10
10
|
} from "react";
|
|
11
11
|
import type { Handle } from "../../handle.js";
|
|
12
|
-
import {
|
|
12
|
+
import { collectHandleData } from "../../handle.js";
|
|
13
13
|
import type { HandleData } from "../types.js";
|
|
14
14
|
import { NavigationStoreContext } from "./context.js";
|
|
15
15
|
import { shallowEqual } from "./shallow-equal.js";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the collect function for a handle.
|
|
19
|
-
* Handle objects are plain { __brand, $$id } - collect is stored in the registry
|
|
20
|
-
* (populated when createHandle runs on the client).
|
|
21
|
-
*/
|
|
22
|
-
function resolveCollect<T, A>(handle: Handle<T, A>): (segments: T[][]) => A {
|
|
23
|
-
// Look up collect from the registry (populated when the handle module is imported).
|
|
24
|
-
const registered = getCollectFn(handle.$$id);
|
|
25
|
-
if (registered) {
|
|
26
|
-
return registered as (segments: T[][]) => A;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Fall back to default flat collect with a dev warning.
|
|
30
|
-
if (process.env.NODE_ENV !== "production") {
|
|
31
|
-
console.warn(
|
|
32
|
-
`[rsc-router] Handle "${handle.$$id}" was passed as a prop but its collect ` +
|
|
33
|
-
`function could not be resolved. Falling back to flat array. ` +
|
|
34
|
-
`Import the handle module in a client component to register its collect function.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
return ((segments: unknown[][]) => segments.flat()) as unknown as (
|
|
38
|
-
segments: T[][],
|
|
39
|
-
) => A;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Collect handle data from segments and transform to final value.
|
|
44
|
-
*/
|
|
45
|
-
function collectHandle<T, A>(
|
|
46
|
-
handle: Handle<T, A>,
|
|
47
|
-
data: HandleData,
|
|
48
|
-
segmentOrder: string[],
|
|
49
|
-
): A {
|
|
50
|
-
const collect = resolveCollect(handle);
|
|
51
|
-
const segmentData = data[handle.$$id];
|
|
52
|
-
|
|
53
|
-
if (!segmentData) {
|
|
54
|
-
return collect([]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Build array of segment arrays in parent -> child order
|
|
58
|
-
const segmentArrays: T[][] = [];
|
|
59
|
-
for (const segmentId of segmentOrder) {
|
|
60
|
-
const entries = segmentData[segmentId];
|
|
61
|
-
if (entries && entries.length > 0) {
|
|
62
|
-
segmentArrays.push(entries as T[]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Call collect once with all segment data
|
|
67
|
-
return collect(segmentArrays);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
17
|
/**
|
|
71
18
|
* Hook to access collected handle data.
|
|
72
19
|
*
|
|
@@ -99,13 +46,13 @@ export function useHandle<T, A, S>(
|
|
|
99
46
|
// Initial state from context event controller, or empty fallback without provider.
|
|
100
47
|
const [value, setValue] = useState<A | S>(() => {
|
|
101
48
|
if (!ctx) {
|
|
102
|
-
const collected =
|
|
49
|
+
const collected = collectHandleData(handle, {}, []);
|
|
103
50
|
return selector ? selector(collected) : collected;
|
|
104
51
|
}
|
|
105
52
|
|
|
106
53
|
// On client, use event controller state
|
|
107
54
|
const state = ctx.eventController.getHandleState();
|
|
108
|
-
const collected =
|
|
55
|
+
const collected = collectHandleData(handle, state.data, state.segmentOrder);
|
|
109
56
|
return selector ? selector(collected) : collected;
|
|
110
57
|
});
|
|
111
58
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -125,7 +72,7 @@ export function useHandle<T, A, S>(
|
|
|
125
72
|
// Sync current state for the (possibly new) handle so that switching
|
|
126
73
|
// handles on an idle page doesn't leave stale data from the old handle.
|
|
127
74
|
const currentHandleState = ctx.eventController.getHandleState();
|
|
128
|
-
const currentCollected =
|
|
75
|
+
const currentCollected = collectHandleData(
|
|
129
76
|
handle,
|
|
130
77
|
currentHandleState.data,
|
|
131
78
|
currentHandleState.segmentOrder,
|
|
@@ -142,7 +89,11 @@ export function useHandle<T, A, S>(
|
|
|
142
89
|
const state = ctx.eventController.getHandleState();
|
|
143
90
|
const isAction =
|
|
144
91
|
ctx.eventController.getState().inflightActions.length > 0;
|
|
145
|
-
const collected =
|
|
92
|
+
const collected = collectHandleData(
|
|
93
|
+
handle,
|
|
94
|
+
state.data,
|
|
95
|
+
state.segmentOrder,
|
|
96
|
+
);
|
|
146
97
|
const nextValue = selectorRef.current
|
|
147
98
|
? selectorRef.current(collected)
|
|
148
99
|
: collected;
|
package/src/handle.ts
CHANGED
|
@@ -133,3 +133,43 @@ export function isHandle(value: unknown): value is Handle<unknown, unknown> {
|
|
|
133
133
|
(value as { __brand: unknown }).__brand === "handle"
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Collect handle data from a HandleData map, applying the handle's collect
|
|
139
|
+
* function over segments in order. Shared between server-side rendered()
|
|
140
|
+
* reads and client-side useHandle().
|
|
141
|
+
*
|
|
142
|
+
* @param handle - The handle to collect data for
|
|
143
|
+
* @param data - Full handle data map (handleName -> segmentId -> entries[])
|
|
144
|
+
* @param segmentOrder - Segment IDs in parent -> child resolution order
|
|
145
|
+
*/
|
|
146
|
+
export function collectHandleData<TData, TAccumulated>(
|
|
147
|
+
handle: Handle<TData, TAccumulated>,
|
|
148
|
+
data: Record<string, Record<string, unknown[]>>,
|
|
149
|
+
segmentOrder: string[],
|
|
150
|
+
): TAccumulated {
|
|
151
|
+
const collectFn = getCollectFn(handle.$$id);
|
|
152
|
+
if (!collectFn && process.env.NODE_ENV !== "production") {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[rsc-router] Handle "${handle.$$id}" has no registered collect function. ` +
|
|
155
|
+
`Falling back to flat array. Ensure the handle module is imported so ` +
|
|
156
|
+
`createHandle() runs and registers the collect function.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const collect = (collectFn ??
|
|
160
|
+
(defaultCollect as unknown as (segments: unknown[][]) => unknown)) as (
|
|
161
|
+
segments: TData[][],
|
|
162
|
+
) => TAccumulated;
|
|
163
|
+
|
|
164
|
+
const segmentData = data[handle.$$id];
|
|
165
|
+
if (!segmentData) return collect([]);
|
|
166
|
+
|
|
167
|
+
const segmentArrays: TData[][] = [];
|
|
168
|
+
for (const segmentId of segmentOrder) {
|
|
169
|
+
const entries = segmentData[segmentId];
|
|
170
|
+
if (entries && entries.length > 0) {
|
|
171
|
+
segmentArrays.push(entries as TData[]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return collect(segmentArrays);
|
|
175
|
+
}
|
|
@@ -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 type { HandleStore } from "../server/handle-store.js";
|
|
23
|
+
import { isHandle, collectHandleData, type Handle } from "../handle.js";
|
|
24
|
+
import type { HandleStore, HandleData } 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,74 @@ 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 = buildHandleSnapshot(
|
|
288
|
+
reqCtx._handleStore,
|
|
289
|
+
segmentOrder,
|
|
290
|
+
);
|
|
291
|
+
return collectHandleData(item, snapshot, segmentOrder);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Loader case
|
|
295
|
+
return useLoader(item as LoaderDefinition<any, any>, currentLoaderId);
|
|
296
|
+
}) as LoaderContext["use"],
|
|
267
297
|
method: "GET",
|
|
268
298
|
body: undefined,
|
|
269
299
|
reverse: ctx.reverse as LoaderContext["reverse"],
|
|
300
|
+
rendered: (): Promise<void> => {
|
|
301
|
+
// Guard: only DSL loaders may use rendered()
|
|
302
|
+
if (!isDslLoader) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
|
|
305
|
+
`Handler-invoked loaders (ctx.use(Loader) inside a handler) cannot use rendered().`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Guard: reject streaming trees
|
|
310
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
311
|
+
if (reqCtx?._treeHasStreaming) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`ctx.rendered() is not supported when the matched route tree uses loading(). ` +
|
|
314
|
+
`Streaming handlers may not have settled when rendered() resolves. ` +
|
|
315
|
+
`Remove loading() from the route tree or restructure to avoid rendered().`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (renderedPromise) return renderedPromise;
|
|
320
|
+
|
|
321
|
+
if (!reqCtx) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`ctx.rendered() failed: request context not available.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Register this loader as waiting for the barrier so that
|
|
328
|
+
// setupLoaderAccess can detect deadlocks when a handler
|
|
329
|
+
// tries to await the same loader via ctx.use().
|
|
330
|
+
if (!reqCtx._renderBarrierWaiters) {
|
|
331
|
+
reqCtx._renderBarrierWaiters = new Set();
|
|
332
|
+
}
|
|
333
|
+
reqCtx._renderBarrierWaiters.add(currentLoaderId);
|
|
334
|
+
|
|
335
|
+
renderedPromise = reqCtx._renderBarrier.then(() => {
|
|
336
|
+
renderedResolved = true;
|
|
337
|
+
});
|
|
338
|
+
return renderedPromise;
|
|
339
|
+
},
|
|
270
340
|
};
|
|
271
341
|
|
|
272
342
|
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
@@ -284,6 +354,25 @@ function createLoaderExecutor<TEnv>(
|
|
|
284
354
|
return useLoader;
|
|
285
355
|
}
|
|
286
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Build a HandleData snapshot from the HandleStore using segment ordering.
|
|
359
|
+
* Reads data directly from the store for each segment in order.
|
|
360
|
+
*/
|
|
361
|
+
function buildHandleSnapshot(
|
|
362
|
+
handleStore: HandleStore,
|
|
363
|
+
segmentOrder: string[],
|
|
364
|
+
): HandleData {
|
|
365
|
+
const data: HandleData = {};
|
|
366
|
+
for (const segmentId of segmentOrder) {
|
|
367
|
+
const segData = handleStore.getDataForSegment(segmentId);
|
|
368
|
+
for (const handleName in segData) {
|
|
369
|
+
if (!data[handleName]) data[handleName] = {};
|
|
370
|
+
data[handleName][segmentId] = segData[handleName];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return data;
|
|
374
|
+
}
|
|
375
|
+
|
|
287
376
|
/**
|
|
288
377
|
* Set up the use() method on handler context to access loaders and handles.
|
|
289
378
|
*
|
|
@@ -334,7 +423,23 @@ export function setupLoaderAccess<TEnv>(
|
|
|
334
423
|
};
|
|
335
424
|
}
|
|
336
425
|
|
|
337
|
-
|
|
426
|
+
// Deadlock guard: if a HANDLER awaits a loader that called rendered(),
|
|
427
|
+
// the handler blocks segment resolution which blocks the barrier.
|
|
428
|
+
// Skip this check when inside a DSL loader scope (resolveLoaderData
|
|
429
|
+
// also calls ctx.use() but that's DSL-to-DSL, not handler-to-loader).
|
|
430
|
+
const loader = item as LoaderDefinition<any, any>;
|
|
431
|
+
if (loaderPromises.has(loader.$$id) && !isInsideLoaderScope()) {
|
|
432
|
+
const reqCtx = _getRequestContext();
|
|
433
|
+
if (reqCtx?._renderBarrierWaiters?.has(loader.$$id)) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`Deadlock: handler is awaiting loader "${loader.$$id}" which called ctx.rendered(). ` +
|
|
436
|
+
`The loader is waiting for segment resolution, but the handler blocks resolution. ` +
|
|
437
|
+
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return useLoader(loader, null);
|
|
338
443
|
}) as typeof ctx.use;
|
|
339
444
|
}
|
|
340
445
|
|
|
@@ -96,6 +96,7 @@ import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
|
96
96
|
import { getRouterContext } from "../router-context.js";
|
|
97
97
|
import { resolveSink, safeEmit } from "../telemetry.js";
|
|
98
98
|
import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
|
|
99
|
+
import { treeHasStreaming } from "./segment-resolution.js";
|
|
99
100
|
import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
|
|
100
101
|
import type { HandleStore } from "../../server/handle-store.js";
|
|
101
102
|
import {
|
|
@@ -193,6 +194,14 @@ async function* yieldFromStore<TEnv>(
|
|
|
193
194
|
state.cachedSegments = segments;
|
|
194
195
|
state.cachedMatchedIds = segments.map((s) => s.id);
|
|
195
196
|
|
|
197
|
+
// Set streaming flag and resolve render barrier.
|
|
198
|
+
const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
|
|
199
|
+
const barrierReqCtx = reqCtx ?? _getRequestContext();
|
|
200
|
+
if (barrierReqCtx) {
|
|
201
|
+
barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
|
|
202
|
+
barrierReqCtx._resolveRenderBarrier(segments);
|
|
203
|
+
}
|
|
204
|
+
|
|
196
205
|
// For partial navigation, nullify components the client already has
|
|
197
206
|
// so parent layouts stay live (client keeps its existing versions).
|
|
198
207
|
// When params changed (e.g., different guide slug), the segments have
|
|
@@ -614,6 +623,13 @@ export function withCacheLookup<TEnv>(
|
|
|
614
623
|
yield segment;
|
|
615
624
|
}
|
|
616
625
|
|
|
626
|
+
// Set streaming flag and resolve render barrier.
|
|
627
|
+
const barrierReqCtx = _getRequestContext();
|
|
628
|
+
if (barrierReqCtx) {
|
|
629
|
+
barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
|
|
630
|
+
barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
|
|
631
|
+
}
|
|
632
|
+
|
|
617
633
|
// Resolve loaders fresh (loaders are NOT cached by default)
|
|
618
634
|
// This ensures fresh data even on cache hit
|
|
619
635
|
const Store = ctx.Store;
|
|
@@ -87,10 +87,49 @@
|
|
|
87
87
|
* if (state.cacheHit) return; // Now we can check
|
|
88
88
|
*/
|
|
89
89
|
import type { ResolvedSegment } from "../../types.js";
|
|
90
|
+
import type { EntryData } from "../../server/context.js";
|
|
91
|
+
import { _getRequestContext } from "../../server/request-context.js";
|
|
90
92
|
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
91
93
|
import { getRouterContext } from "../router-context.js";
|
|
92
94
|
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
93
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Check whether any entry in the tree uses loading() (streaming).
|
|
98
|
+
* Matches the router's streaming semantics in fresh.ts: streaming is
|
|
99
|
+
* enabled when `loading` is defined AND not `false`. `loading: false`
|
|
100
|
+
* explicitly disables streaming; `undefined` means no loading at all.
|
|
101
|
+
*/
|
|
102
|
+
export function treeHasStreaming(entries: EntryData[]): boolean {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (
|
|
105
|
+
"loading" in entry &&
|
|
106
|
+
entry.loading !== undefined &&
|
|
107
|
+
entry.loading !== false
|
|
108
|
+
)
|
|
109
|
+
return true;
|
|
110
|
+
if (entry.layout) {
|
|
111
|
+
if (treeHasStreaming(entry.layout)) return true;
|
|
112
|
+
}
|
|
113
|
+
if (entry.parallel) {
|
|
114
|
+
for (const key in entry.parallel) {
|
|
115
|
+
const parallelEntry = entry.parallel[key as `@${string}`];
|
|
116
|
+
if (parallelEntry) {
|
|
117
|
+
if (
|
|
118
|
+
"loading" in parallelEntry &&
|
|
119
|
+
parallelEntry.loading !== undefined &&
|
|
120
|
+
parallelEntry.loading !== false
|
|
121
|
+
)
|
|
122
|
+
return true;
|
|
123
|
+
if (parallelEntry.layout) {
|
|
124
|
+
if (treeHasStreaming(parallelEntry.layout)) return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
/**
|
|
95
134
|
* Creates segment resolution middleware
|
|
96
135
|
*
|
|
@@ -116,6 +155,7 @@ export function withSegmentResolution<TEnv>(
|
|
|
116
155
|
const ownStart = performance.now();
|
|
117
156
|
|
|
118
157
|
// If cache hit, segments were already yielded by cache lookup
|
|
158
|
+
// (render barrier is resolved on the cache-hit path)
|
|
119
159
|
if (state.cacheHit) {
|
|
120
160
|
if (ms) {
|
|
121
161
|
ms.metrics.push({
|
|
@@ -127,6 +167,11 @@ export function withSegmentResolution<TEnv>(
|
|
|
127
167
|
return;
|
|
128
168
|
}
|
|
129
169
|
|
|
170
|
+
const reqCtx = _getRequestContext();
|
|
171
|
+
if (reqCtx) {
|
|
172
|
+
reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
|
|
131
176
|
getRouterContext<TEnv>();
|
|
132
177
|
|
|
@@ -148,6 +193,10 @@ export function withSegmentResolution<TEnv>(
|
|
|
148
193
|
state.segments = segments;
|
|
149
194
|
state.matchedIds = segments.map((s: { id: string }) => s.id);
|
|
150
195
|
|
|
196
|
+
if (reqCtx) {
|
|
197
|
+
reqCtx._resolveRenderBarrier(segments);
|
|
198
|
+
}
|
|
199
|
+
|
|
151
200
|
// Yield all resolved segments
|
|
152
201
|
for (const segment of segments) {
|
|
153
202
|
yield segment;
|
|
@@ -178,6 +227,10 @@ export function withSegmentResolution<TEnv>(
|
|
|
178
227
|
state.segments = result.segments;
|
|
179
228
|
state.matchedIds = result.matchedIds;
|
|
180
229
|
|
|
230
|
+
if (reqCtx) {
|
|
231
|
+
reqCtx._resolveRenderBarrier(result.segments);
|
|
232
|
+
}
|
|
233
|
+
|
|
181
234
|
// Yield all resolved segments
|
|
182
235
|
for (const segment of result.segments) {
|
|
183
236
|
yield segment;
|
|
@@ -216,6 +216,8 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
216
216
|
_onResponseCallbacks: [],
|
|
217
217
|
setLocationState() {},
|
|
218
218
|
_locationState: undefined,
|
|
219
|
+
_renderBarrier: Promise.resolve(),
|
|
220
|
+
_resolveRenderBarrier: () => {},
|
|
219
221
|
_reportedErrors: new WeakSet<object>(),
|
|
220
222
|
reverse: createReverseFunction(
|
|
221
223
|
deps.mergedRouteMap,
|
|
@@ -450,6 +452,8 @@ export async function renderStaticSegment<TEnv = any>(
|
|
|
450
452
|
_onResponseCallbacks: [],
|
|
451
453
|
setLocationState() {},
|
|
452
454
|
_locationState: undefined,
|
|
455
|
+
_renderBarrier: Promise.resolve(),
|
|
456
|
+
_resolveRenderBarrier: () => {},
|
|
453
457
|
_reportedErrors: new WeakSet<object>(),
|
|
454
458
|
reverse: createReverseFunction(
|
|
455
459
|
mergedRouteMap,
|
|
@@ -709,18 +709,28 @@ export async function resolveLoadersOnly<TEnv>(
|
|
|
709
709
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
710
710
|
for (const layoutEntry of entry.layout) {
|
|
711
711
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
712
|
-
// Inherit route loaders for orphan layouts with parallels
|
|
712
|
+
// Inherit route loaders for orphan layouts with parallels.
|
|
713
|
+
// Resolve directly — do NOT re-enter collectEntryLoaders with the
|
|
714
|
+
// route entry, as that would re-iterate route.layout and loop.
|
|
713
715
|
if (
|
|
714
716
|
entry.type === "route" &&
|
|
715
717
|
entry.loader &&
|
|
716
718
|
entry.loader.length > 0 &&
|
|
717
719
|
Object.keys(layoutEntry.parallel).length > 0
|
|
718
720
|
) {
|
|
719
|
-
await
|
|
721
|
+
const inherited = await resolveLoaders(
|
|
720
722
|
entry,
|
|
723
|
+
context,
|
|
721
724
|
childBelongsToRoute,
|
|
725
|
+
deps,
|
|
722
726
|
layoutEntry.shortCode,
|
|
723
727
|
);
|
|
728
|
+
for (const seg of inherited) {
|
|
729
|
+
if (!seenIds.has(seg.id)) {
|
|
730
|
+
seenIds.add(seg.id);
|
|
731
|
+
loaderSegments.push(seg);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
724
734
|
}
|
|
725
735
|
}
|
|
726
736
|
}
|
|
@@ -319,18 +319,37 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
|
|
|
319
319
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
320
320
|
for (const layoutEntry of entry.layout) {
|
|
321
321
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
322
|
-
// Inherit route loaders for orphan layouts with parallels
|
|
322
|
+
// Inherit route loaders for orphan layouts with parallels.
|
|
323
|
+
// Resolve directly — do NOT re-enter collectEntryLoaders with the
|
|
324
|
+
// route entry, as that would re-iterate route.layout and loop.
|
|
323
325
|
if (
|
|
324
326
|
entry.type === "route" &&
|
|
325
327
|
entry.loader &&
|
|
326
328
|
entry.loader.length > 0 &&
|
|
327
329
|
Object.keys(layoutEntry.parallel).length > 0
|
|
328
330
|
) {
|
|
329
|
-
await
|
|
331
|
+
const inherited = await resolveLoadersWithRevalidation(
|
|
330
332
|
entry,
|
|
333
|
+
context,
|
|
331
334
|
childBelongsToRoute,
|
|
335
|
+
clientSegmentIds,
|
|
336
|
+
prevParams,
|
|
337
|
+
request,
|
|
338
|
+
prevUrl,
|
|
339
|
+
nextUrl,
|
|
340
|
+
routeKey,
|
|
341
|
+
deps,
|
|
342
|
+
actionContext,
|
|
332
343
|
layoutEntry.shortCode,
|
|
344
|
+
stale,
|
|
333
345
|
);
|
|
346
|
+
for (const seg of inherited.segments) {
|
|
347
|
+
if (!seenIds.has(seg.id)) {
|
|
348
|
+
seenIds.add(seg.id);
|
|
349
|
+
allLoaderSegments.push(seg);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
allMatchedIds.push(...inherited.matchedIds);
|
|
334
353
|
}
|
|
335
354
|
}
|
|
336
355
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -698,6 +698,15 @@ export function isInsideCacheScope(): boolean {
|
|
|
698
698
|
return true;
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Check if the current execution is inside a DSL loader scope
|
|
703
|
+
* (wrapped by runInsideLoaderScope). Used by rendered() barrier
|
|
704
|
+
* to distinguish DSL loaders from handler-invoked loaders.
|
|
705
|
+
*/
|
|
706
|
+
export function isInsideLoaderScope(): boolean {
|
|
707
|
+
return loaderScopeALS.getStore()?.active === true;
|
|
708
|
+
}
|
|
709
|
+
|
|
701
710
|
/**
|
|
702
711
|
* Run `fn` inside a loader scope. While active, cache-scope guards
|
|
703
712
|
* are bypassed because loaders are always fresh (never cached) and
|
|
@@ -271,6 +271,41 @@ export interface RequestContext<
|
|
|
271
271
|
/** @internal Previous route key (from the navigation source), used for revalidation */
|
|
272
272
|
_prevRouteKey?: string;
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* @internal Render barrier for experimental `rendered()` API.
|
|
276
|
+
* Resolves when all non-loader segments have settled and handle data
|
|
277
|
+
* is available. Used by DSL loaders that call `ctx.rendered()`.
|
|
278
|
+
*/
|
|
279
|
+
_renderBarrier: Promise<void>;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @internal Resolve the render barrier. Accepts resolved segments, filters
|
|
283
|
+
* out loaders, and captures non-loader segment IDs as the handle ordering.
|
|
284
|
+
* Called after segment resolution (fresh) or handle replay (cache/prerender).
|
|
285
|
+
*/
|
|
286
|
+
_resolveRenderBarrier: (
|
|
287
|
+
segments: Array<{ type: string; id: string }>,
|
|
288
|
+
) => void;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @internal Segment order at barrier resolution time, used by loader
|
|
292
|
+
* ctx.use(handle) to collect handle data in correct order.
|
|
293
|
+
*/
|
|
294
|
+
_renderBarrierSegmentOrder?: string[];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @internal Set to true when the matched entry tree contains any `loading()`
|
|
298
|
+
* entries (streaming). Used by rendered() to fail fast.
|
|
299
|
+
*/
|
|
300
|
+
_treeHasStreaming?: boolean;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @internal Loader IDs that have called rendered() and are waiting for the
|
|
304
|
+
* barrier. Used to detect deadlocks when a handler tries to await the same
|
|
305
|
+
* loader via ctx.use(Loader).
|
|
306
|
+
*/
|
|
307
|
+
_renderBarrierWaiters?: Set<string>;
|
|
308
|
+
|
|
274
309
|
/** @internal Per-request error dedup set for onError reporting */
|
|
275
310
|
_reportedErrors: WeakSet<object>;
|
|
276
311
|
|
|
@@ -322,6 +357,11 @@ export type PublicRequestContext<
|
|
|
322
357
|
| "_routeName"
|
|
323
358
|
| "_prevRouteKey"
|
|
324
359
|
| "_reportedErrors"
|
|
360
|
+
| "_renderBarrier"
|
|
361
|
+
| "_resolveRenderBarrier"
|
|
362
|
+
| "_renderBarrierSegmentOrder"
|
|
363
|
+
| "_treeHasStreaming"
|
|
364
|
+
| "_renderBarrierWaiters"
|
|
325
365
|
| "_reportBackgroundError"
|
|
326
366
|
| "_debugPerformance"
|
|
327
367
|
| "_metricsStore"
|
|
@@ -750,9 +790,50 @@ export function createRequestContext<TEnv>(
|
|
|
750
790
|
_reportedErrors: new WeakSet<object>(),
|
|
751
791
|
_metricsStore: undefined,
|
|
752
792
|
|
|
793
|
+
// Render barrier: deferred promise resolved after non-loader segments settle.
|
|
794
|
+
_renderBarrier: null as any, // set below
|
|
795
|
+
_resolveRenderBarrier: null as any, // set below
|
|
796
|
+
_renderBarrierSegmentOrder: undefined,
|
|
797
|
+
|
|
753
798
|
reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
|
|
754
799
|
};
|
|
755
800
|
|
|
801
|
+
// Lazy render barrier: only allocate the Promise when a loader actually
|
|
802
|
+
// calls rendered(). Requests that don't use rendered() pay zero cost.
|
|
803
|
+
let barrierResolved = false;
|
|
804
|
+
let resolveBarrier: (() => void) | undefined;
|
|
805
|
+
ctx._renderBarrier = null as any; // lazy — created on first access
|
|
806
|
+
ctx._resolveRenderBarrier = (
|
|
807
|
+
segments: Array<{ type: string; id: string }>,
|
|
808
|
+
) => {
|
|
809
|
+
if (barrierResolved) return;
|
|
810
|
+
barrierResolved = true;
|
|
811
|
+
ctx._renderBarrierSegmentOrder = segments
|
|
812
|
+
.filter((s) => s.type !== "loader")
|
|
813
|
+
.map((s) => s.id);
|
|
814
|
+
ctx._renderBarrierWaiters = undefined;
|
|
815
|
+
if (resolveBarrier) resolveBarrier();
|
|
816
|
+
};
|
|
817
|
+
Object.defineProperty(ctx, "_renderBarrier", {
|
|
818
|
+
get() {
|
|
819
|
+
// Barrier already resolved (cache/prerender hit) or first lazy access.
|
|
820
|
+
// Either way, replace the getter with a concrete value to avoid
|
|
821
|
+
// repeated Promise.resolve() allocations on subsequent reads.
|
|
822
|
+
const p = barrierResolved
|
|
823
|
+
? Promise.resolve()
|
|
824
|
+
: new Promise<void>((resolve) => {
|
|
825
|
+
resolveBarrier = resolve;
|
|
826
|
+
});
|
|
827
|
+
Object.defineProperty(ctx, "_renderBarrier", {
|
|
828
|
+
value: p,
|
|
829
|
+
writable: false,
|
|
830
|
+
configurable: false,
|
|
831
|
+
});
|
|
832
|
+
return p;
|
|
833
|
+
},
|
|
834
|
+
configurable: true,
|
|
835
|
+
});
|
|
836
|
+
|
|
756
837
|
// Now create use() with access to ctx
|
|
757
838
|
ctx.use = createUseFunction({
|
|
758
839
|
handleStore,
|
|
@@ -936,12 +1017,12 @@ export function createUseFunction<TEnv>(
|
|
|
936
1017
|
url: ctx.url,
|
|
937
1018
|
env: ctx.env as any,
|
|
938
1019
|
get: ctx.get as any,
|
|
939
|
-
use: <TDep, TDepParams = any>(
|
|
1020
|
+
use: (<TDep, TDepParams = any>(
|
|
940
1021
|
dep: LoaderDefinition<TDep, TDepParams>,
|
|
941
1022
|
): Promise<TDep> => {
|
|
942
1023
|
// Recursive call - will start dep loader if not already started
|
|
943
1024
|
return ctx.use(dep);
|
|
944
|
-
},
|
|
1025
|
+
}) as LoaderContext["use"],
|
|
945
1026
|
method: "GET",
|
|
946
1027
|
body: undefined,
|
|
947
1028
|
reverse: createReverseFunction(
|
|
@@ -950,6 +1031,12 @@ export function createUseFunction<TEnv>(
|
|
|
950
1031
|
ctx.params as Record<string, string>,
|
|
951
1032
|
ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
|
|
952
1033
|
),
|
|
1034
|
+
rendered: () => {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
|
|
1037
|
+
`It cannot be used from request-context loaders or server actions.`,
|
|
1038
|
+
);
|
|
1039
|
+
},
|
|
953
1040
|
};
|
|
954
1041
|
|
|
955
1042
|
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContextVar } from "../context-var.js";
|
|
2
|
+
import type { Handle } from "../handle.js";
|
|
2
3
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
3
4
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
4
5
|
import type { SearchSchema, ResolveSearchSchema } from "../search-params.js";
|
|
@@ -57,11 +58,38 @@ export type LoaderContext<
|
|
|
57
58
|
<T>(contextVar: ContextVar<T>): T | undefined;
|
|
58
59
|
} & (<K extends keyof DefaultVars>(key: K) => DefaultVars[K]);
|
|
59
60
|
/**
|
|
60
|
-
* Access another loader's data
|
|
61
|
+
* Access another loader's data, or read handle data after rendered().
|
|
62
|
+
*
|
|
63
|
+
* For loaders: returns a promise (loaders run in parallel).
|
|
64
|
+
* For handles: returns collected data (only after `await ctx.rendered()`).
|
|
61
65
|
*/
|
|
62
|
-
use:
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
use: {
|
|
67
|
+
<T, TLoaderParams = any>(
|
|
68
|
+
loader: LoaderDefinition<T, TLoaderParams>,
|
|
69
|
+
): Promise<T>;
|
|
70
|
+
<TData, TAccumulated = TData[]>(
|
|
71
|
+
handle: Handle<TData, TAccumulated>,
|
|
72
|
+
): TAccumulated;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* **Experimental.** Wait for all non-loader segments to settle.
|
|
76
|
+
*
|
|
77
|
+
* After the returned promise resolves, handle data is available via
|
|
78
|
+
* `ctx.use(handle)`. Only supported in DSL loaders on non-streaming
|
|
79
|
+
* trees (no `loading()`). Throws if called from a handler-invoked
|
|
80
|
+
* loader or when the tree uses streaming.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* const PricesLoader = createLoader(async (ctx) => {
|
|
85
|
+
* "use server";
|
|
86
|
+
* await ctx.rendered();
|
|
87
|
+
* const products = ctx.use(Products); // reads handle data
|
|
88
|
+
* return pricing.getLive(products.map(p => p.id));
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
rendered: () => Promise<void>;
|
|
65
93
|
/**
|
|
66
94
|
* HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
67
95
|
* Available when loader is called via load({ method: "POST", ... })
|
|
@@ -138,6 +138,36 @@ export function generateExprStubs(
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Replace handler call expressions with lightweight stub objects on an
|
|
143
|
+
* existing MagicString. Unlike generateExprStubs (which creates its own
|
|
144
|
+
* MagicString and returns the full result), this integrates into the
|
|
145
|
+
* unified transform pipeline so all transforms share one sourcemap.
|
|
146
|
+
*/
|
|
147
|
+
export function stubHandlerExprs(
|
|
148
|
+
cfg: HandlerTransformConfig,
|
|
149
|
+
bindings: CreateExportBinding[],
|
|
150
|
+
s: MagicString,
|
|
151
|
+
filePath: string,
|
|
152
|
+
isBuild: boolean,
|
|
153
|
+
): boolean {
|
|
154
|
+
let hasChanges = false;
|
|
155
|
+
for (const binding of bindings) {
|
|
156
|
+
const exportName = binding.exportNames[0];
|
|
157
|
+
const handlerId = isBuild
|
|
158
|
+
? hashId(filePath, exportName)
|
|
159
|
+
: `${filePath}#${exportName}`;
|
|
160
|
+
|
|
161
|
+
s.overwrite(
|
|
162
|
+
binding.callExprStart,
|
|
163
|
+
binding.callCloseParenPos + 1,
|
|
164
|
+
`{ __brand: "${cfg.brand}", $$id: "${handlerId}" }`,
|
|
165
|
+
);
|
|
166
|
+
hasChanges = true;
|
|
167
|
+
}
|
|
168
|
+
return hasChanges;
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
/**
|
|
142
172
|
* Inject $$id into export const handler calls in RSC environments.
|
|
143
173
|
*/
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
transformLocationState,
|
|
35
35
|
generateWholeFileStubs,
|
|
36
36
|
generateExprStubs,
|
|
37
|
+
stubHandlerExprs,
|
|
37
38
|
transformHandlerIds,
|
|
38
39
|
} from "./expose-ids/handler-transform.js";
|
|
39
40
|
|
|
@@ -385,7 +386,9 @@ ${lazyImports.join(",\n")}
|
|
|
385
386
|
if (stubResult) return stubResult;
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
// --- PrerenderHandler: non-RSC stub replacement ---
|
|
389
|
+
// --- PrerenderHandler: non-RSC whole-file stub replacement ---
|
|
390
|
+
// When ALL exports are Prerender() calls, replace the entire file.
|
|
391
|
+
// Mixed-export files are handled in the unified pipeline below.
|
|
389
392
|
if (hasPrerenderHandlerCode && !isRscEnv) {
|
|
390
393
|
const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
|
|
391
394
|
const bindings = getBindings(code, fnNames);
|
|
@@ -397,16 +400,6 @@ ${lazyImports.join(",\n")}
|
|
|
397
400
|
isBuild,
|
|
398
401
|
);
|
|
399
402
|
if (wholeFile) return wholeFile;
|
|
400
|
-
|
|
401
|
-
const exprStubs = generateExprStubs(
|
|
402
|
-
PRERENDER_CONFIG,
|
|
403
|
-
bindings,
|
|
404
|
-
code,
|
|
405
|
-
filePath,
|
|
406
|
-
id,
|
|
407
|
-
isBuild,
|
|
408
|
-
);
|
|
409
|
-
if (exprStubs) return exprStubs;
|
|
410
403
|
}
|
|
411
404
|
|
|
412
405
|
// --- PrerenderHandler: RSC build module tracking ---
|
|
@@ -467,7 +460,9 @@ ${lazyImports.join(",\n")}
|
|
|
467
460
|
}
|
|
468
461
|
}
|
|
469
462
|
|
|
470
|
-
// --- StaticHandler: non-RSC stub replacement ---
|
|
463
|
+
// --- StaticHandler: non-RSC whole-file stub replacement ---
|
|
464
|
+
// When ALL exports are Static() calls, replace the entire file.
|
|
465
|
+
// Mixed-export files are handled in the unified pipeline below.
|
|
471
466
|
if (hasStaticHandlerCode && !isRscEnv) {
|
|
472
467
|
const fnNames = getFnNames(STATIC_CONFIG.fnName);
|
|
473
468
|
const bindings = getBindings(code, fnNames);
|
|
@@ -479,16 +474,6 @@ ${lazyImports.join(",\n")}
|
|
|
479
474
|
isBuild,
|
|
480
475
|
);
|
|
481
476
|
if (wholeFile) return wholeFile;
|
|
482
|
-
|
|
483
|
-
const exprStubs = generateExprStubs(
|
|
484
|
-
STATIC_CONFIG,
|
|
485
|
-
bindings,
|
|
486
|
-
code,
|
|
487
|
-
filePath,
|
|
488
|
-
id,
|
|
489
|
-
isBuild,
|
|
490
|
-
);
|
|
491
|
-
if (exprStubs) return exprStubs;
|
|
492
477
|
}
|
|
493
478
|
|
|
494
479
|
// --- StaticHandler: RSC build module tracking ---
|
|
@@ -535,27 +520,48 @@ ${lazyImports.join(",\n")}
|
|
|
535
520
|
isBuild,
|
|
536
521
|
) || changed;
|
|
537
522
|
}
|
|
538
|
-
if (hasPrerenderHandlerCode
|
|
523
|
+
if (hasPrerenderHandlerCode) {
|
|
539
524
|
const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
525
|
+
const bindings = getBindings(code, fnNames);
|
|
526
|
+
if (isRscEnv) {
|
|
527
|
+
changed =
|
|
528
|
+
transformHandlerIds(
|
|
529
|
+
PRERENDER_CONFIG,
|
|
530
|
+
bindings,
|
|
531
|
+
s,
|
|
532
|
+
filePath,
|
|
533
|
+
isBuild,
|
|
534
|
+
) || changed;
|
|
535
|
+
} else {
|
|
536
|
+
// Non-RSC mixed-export file: replace Prerender() calls with stubs
|
|
537
|
+
// on the shared MagicString so sourcemaps stay accurate.
|
|
538
|
+
changed =
|
|
539
|
+
stubHandlerExprs(
|
|
540
|
+
PRERENDER_CONFIG,
|
|
541
|
+
bindings,
|
|
542
|
+
s,
|
|
543
|
+
filePath,
|
|
544
|
+
isBuild,
|
|
545
|
+
) || changed;
|
|
546
|
+
}
|
|
548
547
|
}
|
|
549
|
-
if (hasStaticHandlerCode
|
|
548
|
+
if (hasStaticHandlerCode) {
|
|
550
549
|
const fnNames = getFnNames(STATIC_CONFIG.fnName);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
550
|
+
const bindings = getBindings(code, fnNames);
|
|
551
|
+
if (isRscEnv) {
|
|
552
|
+
changed =
|
|
553
|
+
transformHandlerIds(
|
|
554
|
+
STATIC_CONFIG,
|
|
555
|
+
bindings,
|
|
556
|
+
s,
|
|
557
|
+
filePath,
|
|
558
|
+
isBuild,
|
|
559
|
+
) || changed;
|
|
560
|
+
} else {
|
|
561
|
+
changed =
|
|
562
|
+
stubHandlerExprs(STATIC_CONFIG, bindings, s, filePath, isBuild) ||
|
|
563
|
+
changed;
|
|
564
|
+
}
|
|
559
565
|
}
|
|
560
566
|
|
|
561
567
|
if (!changed) return;
|
|
@@ -480,9 +480,31 @@ export function createRouterDiscoveryPlugin(
|
|
|
480
480
|
return;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
483
|
+
// Import the user's entry module to force re-evaluation of any
|
|
484
|
+
// HMR-invalidated modules in the chain (entry → router → urls → handlers).
|
|
485
|
+
// This ensures createRouter() re-runs with updated handler code before
|
|
486
|
+
// we read RouterRegistry. Without this, edits to prerender handler files
|
|
487
|
+
// produce stale content because the old router instance remains registered.
|
|
488
|
+
const rscEnv = (server.environments as any)?.rsc;
|
|
489
|
+
let registry: Map<string, any> | null = null;
|
|
490
|
+
if (rscEnv?.runner && s.resolvedEntryPath) {
|
|
491
|
+
try {
|
|
492
|
+
await rscEnv.runner.import(s.resolvedEntryPath);
|
|
493
|
+
const serverMod = await rscEnv.runner.import(
|
|
494
|
+
"@rangojs/router/server",
|
|
495
|
+
);
|
|
496
|
+
registry = serverMod.RouterRegistry ?? null;
|
|
497
|
+
} catch (err: any) {
|
|
498
|
+
console.warn(
|
|
499
|
+
`[rsc-router] Dev prerender module refresh failed: ${err.message}`,
|
|
500
|
+
);
|
|
501
|
+
res.statusCode = 500;
|
|
502
|
+
res.end(`Prerender handler error: ${err.message}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
registry = mainRegistry;
|
|
507
|
+
}
|
|
486
508
|
|
|
487
509
|
if (!registry) {
|
|
488
510
|
// No main registry: the RSC env has no module runner (Cloudflare dev).
|