@rangojs/router 0.0.0-experimental.65 → 0.0.0-experimental.66

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.
@@ -1733,7 +1733,7 @@ import { resolve } from "node:path";
1733
1733
  // package.json
1734
1734
  var package_default = {
1735
1735
  name: "@rangojs/router",
1736
- version: "0.0.0-experimental.65",
1736
+ version: "0.0.0-experimental.66",
1737
1737
  description: "Django-inspired RSC router with composable URL patterns",
1738
1738
  keywords: [
1739
1739
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.65",
3
+ "version": "0.0.0-experimental.66",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -21,7 +21,7 @@ import type {
21
21
  } from "../types";
22
22
  import type { LoaderRevalidationResult, ActionContext } from "./types";
23
23
  import { isHandle, collectHandleData, type Handle } from "../handle.js";
24
- import type { HandleStore, HandleData } from "../server/handle-store.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
27
  import { isInsideLoaderScope } from "../server/context.js";
@@ -284,10 +284,9 @@ function createLoaderExecutor<TEnv>(
284
284
  );
285
285
  }
286
286
  const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
287
- const snapshot = buildHandleSnapshot(
288
- reqCtx._handleStore,
289
- segmentOrder,
290
- );
287
+ const snapshot =
288
+ reqCtx._renderBarrierHandleSnapshot ??
289
+ buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
291
290
  return collectHandleData(item, snapshot, segmentOrder);
292
291
  }
293
292
 
@@ -324,6 +323,17 @@ function createLoaderExecutor<TEnv>(
324
323
  );
325
324
  }
326
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
+
327
337
  // Register this loader as waiting for the barrier so that
328
338
  // setupLoaderAccess can detect deadlocks when a handler
329
339
  // tries to await the same loader via ctx.use().
@@ -354,25 +364,6 @@ function createLoaderExecutor<TEnv>(
354
364
  return useLoader;
355
365
  }
356
366
 
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
-
376
367
  /**
377
368
  * Set up the use() method on handler context to access loaders and handles.
378
369
  *
@@ -386,15 +377,22 @@ export function setupLoaderAccess<TEnv>(
386
377
  ctx: HandlerContext<any, TEnv>,
387
378
  loaderPromises: Map<string, Promise<any>>,
388
379
  ): void {
389
- // Eagerly capture the HandleStore at setup time (before pipeline async ops).
390
- // In workerd/Cloudflare, dynamic imports and fetch() in the match pipeline
391
- // can disrupt AsyncLocalStorage, causing getRequestContext() to return
392
- // undefined when handlers later call ctx.use(handle). Capturing early
393
- // ensures the store reference survives ALS disruption.
394
- const handleStoreRef = _getRequestContext()?._handleStore;
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;
395
387
 
396
388
  const useLoader = createLoaderExecutor(ctx, loaderPromises);
397
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
+
398
396
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
399
397
  if (isHandle(item)) {
400
398
  const handle = item;
@@ -414,28 +412,53 @@ export function setupLoaderAccess<TEnv>(
414
412
  ) => {
415
413
  if (!store) return;
416
414
 
417
- const valueOrPromise =
418
- typeof dataOrFn === "function"
419
- ? (dataOrFn as () => Promise<unknown>)()
420
- : dataOrFn;
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
+ }
421
427
 
422
- store.push(handle.$$id, segmentId, valueOrPromise);
428
+ store.push(handle.$$id, segmentId, dataOrFn);
423
429
  };
424
430
  }
425
431
 
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).
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).
430
437
  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
- );
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
+ }
439
462
  }
440
463
  }
441
464
 
@@ -194,11 +194,13 @@ async function* yieldFromStore<TEnv>(
194
194
  state.cachedSegments = segments;
195
195
  state.cachedMatchedIds = segments.map((s) => s.id);
196
196
 
197
- // Set streaming flag and resolve render barrier.
197
+ // Set streaming flag (once) and resolve render barrier.
198
198
  const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
199
199
  const barrierReqCtx = reqCtx ?? _getRequestContext();
200
200
  if (barrierReqCtx) {
201
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
201
+ if (barrierReqCtx._treeHasStreaming === undefined) {
202
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
203
+ }
202
204
  barrierReqCtx._resolveRenderBarrier(segments);
203
205
  }
204
206
 
@@ -623,10 +625,12 @@ export function withCacheLookup<TEnv>(
623
625
  yield segment;
624
626
  }
625
627
 
626
- // Set streaming flag and resolve render barrier.
628
+ // Set streaming flag (once) and resolve render barrier.
627
629
  const barrierReqCtx = _getRequestContext();
628
630
  if (barrierReqCtx) {
629
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
631
+ if (barrierReqCtx._treeHasStreaming === undefined) {
632
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
633
+ }
630
634
  barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
631
635
  }
632
636
 
@@ -168,7 +168,7 @@ export function withSegmentResolution<TEnv>(
168
168
  }
169
169
 
170
170
  const reqCtx = _getRequestContext();
171
- if (reqCtx) {
171
+ if (reqCtx && reqCtx._treeHasStreaming === undefined) {
172
172
  reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
173
173
  }
174
174
 
@@ -1090,7 +1090,11 @@ export function createRSCHandler<
1090
1090
  },
1091
1091
  };
1092
1092
 
1093
- const rscStream = renderToReadableStream(payload);
1093
+ const rscStream = renderToReadableStream(payload, {
1094
+ onError: (error: unknown) => {
1095
+ callOnError(error, "rendering", { request, url, env });
1096
+ },
1097
+ });
1094
1098
 
1095
1099
  const isRscRequest =
1096
1100
  isPartial ||
@@ -168,8 +168,19 @@ export async function handleLoaderFetch<TEnv>(
168
168
  loaderResult: unknown;
169
169
  }
170
170
  const loaderPayload: LoaderPayload = { loaderResult: result };
171
- const rscStream =
172
- ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
171
+ const rscStream = ctx.renderToReadableStream<LoaderPayload>(
172
+ loaderPayload,
173
+ {
174
+ onError: (error: unknown) => {
175
+ ctx.callOnError(error, "rendering", {
176
+ request,
177
+ url,
178
+ env,
179
+ loaderName: loaderId,
180
+ });
181
+ },
182
+ },
183
+ );
173
184
 
174
185
  return createResponseWithMergedHeaders(rscStream, {
175
186
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -199,7 +210,16 @@ export async function handleLoaderFetch<TEnv>(
199
210
  name: err.name,
200
211
  },
201
212
  };
202
- const rscStream = ctx.renderToReadableStream(errorPayload);
213
+ const rscStream = ctx.renderToReadableStream(errorPayload, {
214
+ onError: (error: unknown) => {
215
+ ctx.callOnError(error, "rendering", {
216
+ request,
217
+ url,
218
+ env,
219
+ loaderName: loaderId,
220
+ });
221
+ },
222
+ });
203
223
 
204
224
  return createResponseWithMergedHeaders(rscStream, {
205
225
  status: 500,
@@ -259,7 +259,11 @@ export async function handleProgressiveEnhancement<TEnv>(
259
259
  formState: actionResult,
260
260
  };
261
261
 
262
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
262
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
263
+ onError: (error: unknown) => {
264
+ ctx.callOnError(error, "rendering", { request, url, env });
265
+ },
266
+ });
263
267
  // metricsStore=undefined is safe: the handler already stashed the early
264
268
  // SSR setup promise on request variables, so getSSRSetup returns it
265
269
  // without falling back to a fresh startSSRSetup.
@@ -360,7 +364,11 @@ async function renderPeErrorBoundary<TEnv>(
360
364
  },
361
365
  };
362
366
 
363
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
367
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
368
+ onError: (error: unknown) => {
369
+ ctx.callOnError(error, "rendering", { request, url, env });
370
+ },
371
+ });
364
372
  // metricsStore=undefined is safe: the handler already stashed the early
365
373
  // SSR setup promise on request variables, so getSSRSetup returns it
366
374
  // without falling back to a fresh startSSRSetup.
@@ -173,7 +173,11 @@ export async function handleRscRendering<TEnv>(
173
173
 
174
174
  // Serialize to RSC stream
175
175
  const rscSerializeStart = performance.now();
176
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
176
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
177
+ onError: (error: unknown) => {
178
+ ctx.callOnError(error, "rendering", { request, url, env });
179
+ },
180
+ });
177
181
  const rscSerializeDur = performance.now() - rscSerializeStart;
178
182
  // This measures synchronous stream creation, not end-to-end stream consumption.
179
183
  appendMetric(
@@ -226,6 +226,9 @@ export async function executeServerAction<TEnv>(
226
226
 
227
227
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
228
228
  temporaryReferences,
229
+ onError: (error: unknown) => {
230
+ ctx.callOnError(error, "rendering", { request, url, env });
231
+ },
229
232
  });
230
233
 
231
234
  return createResponseWithMergedHeaders(rscStream, {
@@ -332,6 +335,9 @@ export async function revalidateAfterAction<TEnv>(
332
335
  const renderStart = performance.now();
333
336
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
334
337
  temporaryReferences,
338
+ onError: (error: unknown) => {
339
+ ctx.callOnError(error, "rendering", { request, url, env });
340
+ },
335
341
  });
336
342
  const rscSerializeDur = performance.now() - renderStart;
337
343
  // This measures synchronous stream creation, not end-to-end stream consumption.
package/src/rsc/types.ts CHANGED
@@ -70,6 +70,7 @@ export interface RSCDependencies {
70
70
  payload: T,
71
71
  options?: {
72
72
  temporaryReferences?: unknown;
73
+ onError?: (error: unknown) => string | void;
73
74
  },
74
75
  ) => ReadableStream<Uint8Array>;
75
76
 
@@ -13,6 +13,25 @@
13
13
  */
14
14
  export type HandleData = Record<string, Record<string, unknown[]>>;
15
15
 
16
+ /**
17
+ * Build a HandleData snapshot from a HandleStore using segment ordering.
18
+ * Reads data directly from the store for each segment in order.
19
+ */
20
+ export function buildHandleSnapshot(
21
+ handleStore: HandleStore,
22
+ segmentOrder: string[],
23
+ ): HandleData {
24
+ const data: HandleData = {};
25
+ for (const segmentId of segmentOrder) {
26
+ const segData = handleStore.getDataForSegment(segmentId);
27
+ for (const handleName in segData) {
28
+ if (!data[handleName]) data[handleName] = {};
29
+ data[handleName][segmentId] = segData[handleName];
30
+ }
31
+ }
32
+ return data;
33
+ }
34
+
16
35
  function createLateHandlePushError(
17
36
  handleName: string,
18
37
  segmentId: string,
@@ -26,7 +26,12 @@ import {
26
26
  contextSet,
27
27
  isNonCacheable,
28
28
  } from "../context-var.js";
29
- import { createHandleStore, type HandleStore } from "./handle-store.js";
29
+ import {
30
+ createHandleStore,
31
+ buildHandleSnapshot,
32
+ type HandleStore,
33
+ type HandleData,
34
+ } from "./handle-store.js";
30
35
  import { isHandle } from "../handle.js";
31
36
  import { track, type MetricsStore } from "./context.js";
32
37
  import { getFetchableLoader } from "./fetchable-loader-store.js";
@@ -306,6 +311,19 @@ export interface RequestContext<
306
311
  */
307
312
  _renderBarrierWaiters?: Set<string>;
308
313
 
314
+ /**
315
+ * @internal Loader IDs that handlers have started awaiting via ctx.use().
316
+ * Used for bidirectional deadlock detection: if a loader later calls
317
+ * rendered() and a handler already awaits it, we can detect the deadlock.
318
+ */
319
+ _handlerLoaderDeps?: Set<string>;
320
+
321
+ /**
322
+ * @internal Cached HandleData snapshot built at barrier resolution time.
323
+ * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
324
+ */
325
+ _renderBarrierHandleSnapshot?: HandleData;
326
+
309
327
  /** @internal Per-request error dedup set for onError reporting */
310
328
  _reportedErrors: WeakSet<object>;
311
329
 
@@ -362,6 +380,8 @@ export type PublicRequestContext<
362
380
  | "_renderBarrierSegmentOrder"
363
381
  | "_treeHasStreaming"
364
382
  | "_renderBarrierWaiters"
383
+ | "_handlerLoaderDeps"
384
+ | "_renderBarrierHandleSnapshot"
365
385
  | "_reportBackgroundError"
366
386
  | "_debugPerformance"
367
387
  | "_metricsStore"
@@ -808,10 +828,18 @@ export function createRequestContext<TEnv>(
808
828
  ) => {
809
829
  if (barrierResolved) return;
810
830
  barrierResolved = true;
811
- ctx._renderBarrierSegmentOrder = segments
831
+ const segOrder = segments
812
832
  .filter((s) => s.type !== "loader")
813
833
  .map((s) => s.id);
834
+ ctx._renderBarrierSegmentOrder = segOrder;
835
+ // Build and cache handle snapshot so loader ctx.use(handle) calls
836
+ // don't rebuild it on every invocation.
837
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
838
+ handleStore,
839
+ segOrder,
840
+ );
814
841
  ctx._renderBarrierWaiters = undefined;
842
+ ctx._handlerLoaderDeps = undefined;
815
843
  if (resolveBarrier) resolveBarrier();
816
844
  };
817
845
  Object.defineProperty(ctx, "_renderBarrier", {