@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.
@@ -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 generateExprStubs(cfg, bindings, code, filePath, sourceId, isBuild) {
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
- if (!hasChanges) return null;
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 && isRscEnv) {
1347
+ if (hasPrerenderHandlerCode) {
1376
1348
  const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
1377
- changed = transformHandlerIds(
1378
- PRERENDER_CONFIG,
1379
- getBindings(code, fnNames),
1380
- s,
1381
- filePath,
1382
- isBuild
1383
- ) || changed;
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 && isRscEnv) {
1368
+ if (hasStaticHandlerCode) {
1386
1369
  const fnNames = getFnNames(STATIC_CONFIG.fnName);
1387
- changed = transformHandlerIds(
1388
- STATIC_CONFIG,
1389
- getBindings(code, fnNames),
1390
- s,
1391
- filePath,
1392
- isBuild
1393
- ) || changed;
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.64",
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
- let registry = mainRegistry;
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.64",
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": "^19.2.7",
145
- "@types/react-dom": "^19.2.3",
153
+ "@types/react": "catalog:",
154
+ "@types/react-dom": "catalog:",
146
155
  "esbuild": "^0.27.0",
147
156
  "jiti": "^2.6.1",
148
- "react": "^19.2.4",
149
- "react-dom": "^19.2.4",
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 { getCollectFn } from "../../handle.js";
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 = collectHandle(handle, {}, []);
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 = collectHandle(handle, state.data, state.segmentOrder);
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 = collectHandle(
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 = collectHandle(handle, state.data, state.segmentOrder);
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: <TDep, TDepParams = any>(
263
- dep: LoaderDefinition<TDep, TDepParams>,
264
- ): Promise<TDep> => {
265
- return useLoader(dep, currentLoaderId);
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
- return useLoader(item as LoaderDefinition<any, any>, null);
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 collectEntryLoaders(
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 collectEntryLoaders(
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
  }
@@ -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 (returns promise since loaders run in parallel)
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: <T, TLoaderParams = any>(
63
- loader: LoaderDefinition<T, TLoaderParams>,
64
- ) => Promise<T>;
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 && isRscEnv) {
523
+ if (hasPrerenderHandlerCode) {
539
524
  const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
540
- changed =
541
- transformHandlerIds(
542
- PRERENDER_CONFIG,
543
- getBindings(code, fnNames),
544
- s,
545
- filePath,
546
- isBuild,
547
- ) || changed;
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 && isRscEnv) {
548
+ if (hasStaticHandlerCode) {
550
549
  const fnNames = getFnNames(STATIC_CONFIG.fnName);
551
- changed =
552
- transformHandlerIds(
553
- STATIC_CONFIG,
554
- getBindings(code, fnNames),
555
- s,
556
- filePath,
557
- isBuild,
558
- ) || changed;
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
- // Prefer the main server's registry (Node.js preset: module runner available).
484
- // Fall back to a temp server for Cloudflare where the main RSC env uses workerd.
485
- let registry = mainRegistry;
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).