@rangojs/router 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +1 -1
- package/dist/types/browser/action-fence.d.ts +2 -1
- package/dist/types/browser/event-controller.d.ts +6 -1
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/use-action.d.ts +10 -4
- package/dist/types/browser/react/use-handle.d.ts +20 -16
- package/dist/types/browser/react/use-link-status.d.ts +5 -2
- package/dist/types/browser/react/use-route-state.d.ts +45 -0
- package/dist/types/browser/segment-reconciler.d.ts +26 -0
- package/dist/types/browser/types.d.ts +30 -8
- package/dist/types/browser/wire-protocol.d.ts +1 -0
- package/dist/types/cache/tag-invalidation.d.ts +0 -10
- package/dist/types/router/error-handling.d.ts +7 -1
- package/dist/types/router/match-context.d.ts +2 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
- package/dist/types/router/navigation-snapshot.d.ts +7 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
- package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
- package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
- package/dist/types/router/types.d.ts +1 -1
- package/dist/types/server/request-context.d.ts +30 -1
- package/dist/types/testing/render-route.d.ts +43 -8
- package/dist/types/testing/serve-shell-request.d.ts +8 -1
- package/dist/types/types/error-types.d.ts +27 -8
- package/dist/types/wire-params.d.ts +5 -0
- package/dist/vite/index.js +62 -67
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +6 -8
- package/skills/hooks/SKILL.md +1 -1
- package/skills/hooks/data.md +8 -1
- package/skills/hooks/handle-and-actions.md +40 -2
- package/skills/hooks/navigation.md +121 -15
- package/skills/hooks/state.md +6 -4
- package/skills/hooks/urls.md +17 -5
- package/skills/intercept/SKILL.md +6 -0
- package/skills/observability/SKILL.md +30 -0
- package/skills/server-actions/SKILL.md +8 -1
- package/skills/testing/client-components.md +25 -18
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/action-fence.ts +2 -1
- package/src/browser/event-controller.ts +78 -19
- package/src/browser/navigation-bridge.ts +45 -13
- package/src/browser/navigation-client.ts +2 -0
- package/src/browser/network-error-handler.ts +4 -0
- package/src/browser/partial-update.ts +33 -6
- package/src/browser/react/use-action.ts +27 -50
- package/src/browser/react/use-handle.ts +27 -18
- package/src/browser/react/use-link-status.ts +48 -60
- package/src/browser/react/use-navigation.ts +76 -51
- package/src/browser/react/use-params.ts +6 -35
- package/src/browser/react/use-pathname.ts +3 -32
- package/src/browser/react/use-route-state.ts +141 -0
- package/src/browser/react/use-search-params.ts +7 -40
- package/src/browser/segment-reconciler.ts +43 -0
- package/src/browser/server-action-bridge.ts +35 -8
- package/src/browser/types.ts +31 -8
- package/src/browser/wire-protocol.ts +2 -0
- package/src/cache/cache-runtime.ts +28 -31
- package/src/cache/document-cache.ts +34 -37
- package/src/cache/read-through-swr.ts +20 -18
- package/src/cache/tag-invalidation.ts +3 -18
- package/src/router/error-handling.ts +18 -6
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/match-api.ts +1 -0
- package/src/router/match-context.ts +3 -0
- package/src/router/match-middleware/background-revalidation.ts +5 -4
- package/src/router/match-middleware/cache-lookup.ts +5 -3
- package/src/router/navigation-snapshot.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +49 -13
- package/src/router/segment-resolution/loader-cache.ts +4 -3
- package/src/router/segment-resolution/resolution-pass.ts +2 -0
- package/src/router/segment-resolution/revalidation.ts +57 -28
- package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
- package/src/router/types.ts +1 -1
- package/src/router.ts +7 -4
- package/src/rsc/request-stages.ts +4 -2
- package/src/rsc/shell-capture.ts +63 -58
- package/src/server/request-context.ts +49 -0
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +204 -16
- package/src/testing/serve-shell-request.ts +19 -10
- package/src/types/error-types.ts +27 -8
- package/src/wire-params.ts +5 -0
|
@@ -113,7 +113,7 @@ import { getContext } from "../../server/context.js";
|
|
|
113
113
|
import { executionStart } from "../../cache/tag-invalidation.js";
|
|
114
114
|
import {
|
|
115
115
|
createRequestContext,
|
|
116
|
-
|
|
116
|
+
runAsBackgroundWork,
|
|
117
117
|
type RequestContext,
|
|
118
118
|
} from "../../server/request-context.js";
|
|
119
119
|
import { deriveRenderPass } from "../../server/render-pass.js";
|
|
@@ -141,8 +141,9 @@ import {
|
|
|
141
141
|
* each field and why. The DSL store below has `metrics` unset too (track()
|
|
142
142
|
* reads that one), so nothing lands on the foreground's perf timeline;
|
|
143
143
|
* ctx.Store.run closes over ctx.Store, so the derived store needs its own run.
|
|
144
|
-
*
|
|
145
|
-
* task on workerd loses
|
|
144
|
+
* runAsBackgroundWork also re-establishes the request ALS, which a waitUntil
|
|
145
|
+
* task on workerd loses, and marks the pass's onError reports `background`;
|
|
146
|
+
* the DSL store is a different ALS (build context).
|
|
146
147
|
*/
|
|
147
148
|
export async function rerenderAndCacheRoute<TEnv>(
|
|
148
149
|
ctx: MatchContext<TEnv>,
|
|
@@ -172,7 +173,7 @@ export async function rerenderAndCacheRoute<TEnv>(
|
|
|
172
173
|
store.parent,
|
|
173
174
|
fn,
|
|
174
175
|
);
|
|
175
|
-
return
|
|
176
|
+
return runAsBackgroundWork(renderCtx, async () => {
|
|
176
177
|
const handlerContext = routerCtx.createHandlerContext(
|
|
177
178
|
ctx.matched.params,
|
|
178
179
|
ctx.request,
|
|
@@ -431,13 +431,15 @@ async function* serveStoredSegments<TEnv>(
|
|
|
431
431
|
// route is the same case, served from the overlay first (an overlay entry
|
|
432
432
|
// is newer than the build one, and may be the only one). Passthrough
|
|
433
433
|
// routes keep a liveHandler, so a live render (rendersLive) re-renders
|
|
434
|
-
// them fresh. A
|
|
435
|
-
// (ActionContext.boundary)
|
|
434
|
+
// them fresh. A pass cut at an error boundary walks live too
|
|
435
|
+
// (ActionContext.boundary, a follow-up's held one): the evicted handler is
|
|
436
|
+
// under the cut.
|
|
436
437
|
if (
|
|
437
438
|
(prerenderStoreShortCircuits(ctx.matched.pr, ctx.request) ||
|
|
438
439
|
overlayEligible) &&
|
|
439
440
|
(!rendersLive(ctx) || !isPassthroughPrerenderRoute) &&
|
|
440
|
-
!ctx.actionContext?.boundary
|
|
441
|
+
!ctx.actionContext?.boundary &&
|
|
442
|
+
!ctx.heldBoundary
|
|
441
443
|
) {
|
|
442
444
|
await ensurePrerenderDeps();
|
|
443
445
|
stored = await readPrerenderSource(
|
|
@@ -18,6 +18,13 @@ export interface NavigationSnapshot {
|
|
|
18
18
|
filteredSegmentIds: string[];
|
|
19
19
|
|
|
20
20
|
stale: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The id of an action's error boundary on screen that a stale request holds
|
|
23
|
+
* (`_rsc_boundary`, the action follow-up): read only beside `stale`, so it
|
|
24
|
+
* has every stale guarantee (a live render, no route-cache or document-cache
|
|
25
|
+
* read or store, no PPR replay).
|
|
26
|
+
*/
|
|
27
|
+
heldBoundary?: string;
|
|
21
28
|
|
|
22
29
|
isSameRouteNavigation: boolean;
|
|
23
30
|
|
|
@@ -136,6 +143,8 @@ export async function resolveNavigation(
|
|
|
136
143
|
const clientSegmentIds =
|
|
137
144
|
url.searchParams.get(WIRE.segments)?.split(",").filter(Boolean) || [];
|
|
138
145
|
const stale = url.searchParams.get(WIRE.stale) === "true";
|
|
146
|
+
const heldBoundary =
|
|
147
|
+
(stale && url.searchParams.get(WIRE.boundary)) || undefined;
|
|
139
148
|
const previousUrl = getNavigationContextHeader(request, url.origin);
|
|
140
149
|
const interceptSourceUrl = getInterceptSourceHeader(request);
|
|
141
150
|
const isHmr = !!requestHeaders(request).get("X-RSC-HMR");
|
|
@@ -198,6 +207,7 @@ export async function resolveNavigation(
|
|
|
198
207
|
clientSegmentSet,
|
|
199
208
|
filteredSegmentIds,
|
|
200
209
|
stale,
|
|
210
|
+
heldBoundary,
|
|
201
211
|
isSameRouteNavigation,
|
|
202
212
|
effectiveFromUrl,
|
|
203
213
|
effectiveFromMatch,
|
|
@@ -204,15 +204,49 @@ export interface ErrorReportContext {
|
|
|
204
204
|
routeKey?: string;
|
|
205
205
|
env?: any;
|
|
206
206
|
isPartial?: boolean;
|
|
207
|
-
requestStartTime?: number;
|
|
208
207
|
telemetry?: TelemetrySink;
|
|
209
208
|
}
|
|
210
209
|
|
|
210
|
+
type Thrower = { id: string; type: ErrorInfo["segmentType"] };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Note that `id` threw `error`, for an error its owning entry's boundary
|
|
214
|
+
* catches (a parallel slot's sync throw, an orphan layout's): onError names
|
|
215
|
+
* the segment that threw, as a streamed slot's rejection does. Kept on the
|
|
216
|
+
* request context, never the module: a reused error object (a constant, a
|
|
217
|
+
* memoized rejection) can be thrown by another request, or by a Prerender
|
|
218
|
+
* pass that rethrows before catchSegmentError reads it. Returns `error`, to
|
|
219
|
+
* rethrow.
|
|
220
|
+
*/
|
|
221
|
+
export function noteThrower(
|
|
222
|
+
error: unknown,
|
|
223
|
+
id: string,
|
|
224
|
+
type: ErrorInfo["segmentType"],
|
|
225
|
+
): unknown {
|
|
226
|
+
const reqCtx = _getRequestContext();
|
|
227
|
+
if (reqCtx && error !== null && typeof error === "object") {
|
|
228
|
+
(reqCtx._throwers ??= new WeakMap()).set(error, { id, type });
|
|
229
|
+
}
|
|
230
|
+
return error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The segment noteThrower recorded for `error`, read once. */
|
|
234
|
+
function takeThrower(error: unknown): Thrower | undefined {
|
|
235
|
+
const throwers = _getRequestContext()?._throwers;
|
|
236
|
+
if (!throwers || error === null || typeof error !== "object") return;
|
|
237
|
+
const noted = throwers.get(error) as Thrower | undefined;
|
|
238
|
+
throwers.delete(error);
|
|
239
|
+
return noted;
|
|
240
|
+
}
|
|
241
|
+
|
|
211
242
|
/**
|
|
212
243
|
* Handle a caught error during segment resolution by creating an
|
|
213
244
|
* error or not-found segment with the nearest boundary.
|
|
214
245
|
*
|
|
215
246
|
* Called by resolveWithErrorBoundary to produce error/notFound segments.
|
|
247
|
+
* `entry` is where the boundary is looked up; `thrower` names the segment
|
|
248
|
+
* that threw when it is not `entry` (an intercept's slot, under its declaring
|
|
249
|
+
* entry), for the report, the server print and the client's ErrorInfo.
|
|
216
250
|
*/
|
|
217
251
|
export function catchSegmentError<TEnv>(
|
|
218
252
|
error: unknown,
|
|
@@ -221,7 +255,11 @@ export function catchSegmentError<TEnv>(
|
|
|
221
255
|
deps: SegmentResolutionDeps<TEnv>,
|
|
222
256
|
report?: ErrorReportContext,
|
|
223
257
|
pathname?: string,
|
|
258
|
+
thrower?: Thrower,
|
|
224
259
|
): ResolvedSegment {
|
|
260
|
+
const named = thrower ?? takeThrower(error);
|
|
261
|
+
const segmentId = named?.id ?? entry.shortCode;
|
|
262
|
+
const segmentType: ErrorInfo["segmentType"] = named?.type ?? entry.type;
|
|
225
263
|
const reportError = (
|
|
226
264
|
handledByBoundary: boolean,
|
|
227
265
|
metadata?: Record<string, unknown>,
|
|
@@ -232,13 +270,12 @@ export function catchSegmentError<TEnv>(
|
|
|
232
270
|
url: report.url,
|
|
233
271
|
routeKey: report.routeKey,
|
|
234
272
|
params,
|
|
235
|
-
segmentId
|
|
236
|
-
segmentType:
|
|
273
|
+
segmentId,
|
|
274
|
+
segmentType: segmentType as any,
|
|
237
275
|
env: report.env,
|
|
238
276
|
isPartial: report.isPartial,
|
|
239
277
|
handledByBoundary,
|
|
240
278
|
metadata,
|
|
241
|
-
requestStartTime: report.requestStartTime,
|
|
242
279
|
});
|
|
243
280
|
if (report.telemetry) {
|
|
244
281
|
const errorObj =
|
|
@@ -247,8 +284,8 @@ export function catchSegmentError<TEnv>(
|
|
|
247
284
|
type: "handler.error",
|
|
248
285
|
timestamp: performance.now(),
|
|
249
286
|
requestId: report.request ? getRequestId(report.request) : undefined,
|
|
250
|
-
segmentId
|
|
251
|
-
segmentType
|
|
287
|
+
segmentId,
|
|
288
|
+
segmentType,
|
|
252
289
|
error: errorObj,
|
|
253
290
|
handledByBoundary,
|
|
254
291
|
pathname,
|
|
@@ -274,8 +311,8 @@ export function catchSegmentError<TEnv>(
|
|
|
274
311
|
|
|
275
312
|
const notFoundInfo = createNotFoundInfo(
|
|
276
313
|
error,
|
|
277
|
-
|
|
278
|
-
|
|
314
|
+
segmentId,
|
|
315
|
+
segmentType,
|
|
279
316
|
pathname,
|
|
280
317
|
);
|
|
281
318
|
|
|
@@ -285,7 +322,7 @@ export function catchSegmentError<TEnv>(
|
|
|
285
322
|
});
|
|
286
323
|
|
|
287
324
|
debugLog("segment", "notFound boundary handled error", {
|
|
288
|
-
segmentId
|
|
325
|
+
segmentId,
|
|
289
326
|
message: notFoundInfo.message,
|
|
290
327
|
});
|
|
291
328
|
|
|
@@ -302,19 +339,18 @@ export function catchSegmentError<TEnv>(
|
|
|
302
339
|
// The client gets the masked ErrorInfo; the original prints here.
|
|
303
340
|
printServerError(
|
|
304
341
|
error,
|
|
305
|
-
{ label: "Handler error", name:
|
|
342
|
+
{ label: "Handler error", name: segmentId, pathname },
|
|
306
343
|
_getRequestContext(),
|
|
307
344
|
);
|
|
308
345
|
|
|
309
346
|
const fallback = deps.findNearestErrorBoundary(entry);
|
|
310
|
-
const
|
|
311
|
-
const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
|
|
347
|
+
const errorInfo = createErrorInfo(error, segmentId, segmentType);
|
|
312
348
|
const effectiveFallback = fallback ?? DefaultErrorFallback;
|
|
313
349
|
|
|
314
350
|
reportError(!!effectiveFallback);
|
|
315
351
|
|
|
316
352
|
debugLog("segment", "error boundary handled error", {
|
|
317
|
-
segmentId
|
|
353
|
+
segmentId,
|
|
318
354
|
boundary: fallback ? "custom" : "default",
|
|
319
355
|
message: errorInfo.message,
|
|
320
356
|
});
|
|
@@ -72,6 +72,7 @@ import { runIdentityExempt } from "../../cache/cache-exec-scope.js";
|
|
|
72
72
|
import {
|
|
73
73
|
getRequestContext,
|
|
74
74
|
_getRequestContext,
|
|
75
|
+
runAsBackgroundWork,
|
|
75
76
|
type RequestContext,
|
|
76
77
|
} from "../../server/request-context.js";
|
|
77
78
|
import { observePhase, PHASES } from "../instrument.js";
|
|
@@ -92,7 +93,6 @@ import { isWarmReplace, noteWarmWrite } from "../../prerender/warm-request.js";
|
|
|
92
93
|
import {
|
|
93
94
|
executionStart,
|
|
94
95
|
predatesInvalidation,
|
|
95
|
-
runAsBackgroundRefresh,
|
|
96
96
|
} from "../../cache/tag-invalidation.js";
|
|
97
97
|
import {
|
|
98
98
|
maskNestedContainerThenables,
|
|
@@ -815,10 +815,11 @@ function executeLoaderData<TEnv>(
|
|
|
815
815
|
// under the ended foreground phases. Inside the request context so
|
|
816
816
|
// observePhase can read tracing on workerd (ALS detaches in waitUntil).
|
|
817
817
|
// No visitor made the invalidations it runs: they set no fresh-reads
|
|
818
|
-
// cookie
|
|
818
|
+
// cookie, and onError marks its errors `background`
|
|
819
|
+
// (runAsBackgroundWork).
|
|
819
820
|
wrapBackground: (run) => {
|
|
820
821
|
revalidating = true;
|
|
821
|
-
return
|
|
822
|
+
return runAsBackgroundWork(requestCtxForExecute, () =>
|
|
822
823
|
observePhase(PHASES.background("loader-revalidation"), run),
|
|
823
824
|
);
|
|
824
825
|
},
|
|
@@ -51,6 +51,8 @@ export interface ResolutionPassInput<TEnv = any> {
|
|
|
51
51
|
readonly routeKey: string;
|
|
52
52
|
readonly actionContext?: ActionContext;
|
|
53
53
|
readonly stale?: boolean;
|
|
54
|
+
/** NavigationSnapshot.heldBoundary: the walk cuts there, sending no segment. */
|
|
55
|
+
readonly heldBoundary?: string;
|
|
54
56
|
/**
|
|
55
57
|
* No loader is started and no loader segment is emitted: build time
|
|
56
58
|
* (prerender-match.ts) and the background re-render of a route record
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
resolveLayoutComponent,
|
|
63
63
|
resolveWithErrorBoundary,
|
|
64
64
|
buildLoaderErrorContext,
|
|
65
|
+
noteThrower,
|
|
65
66
|
} from "./helpers.js";
|
|
66
67
|
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
67
68
|
import { isLayoutLoaderCopy } from "../../client-urls/layout-loader-copies.js";
|
|
@@ -801,28 +802,34 @@ async function resolveParallelSlotComponent<TEnv>(
|
|
|
801
802
|
handler,
|
|
802
803
|
slotContext,
|
|
803
804
|
);
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
if (
|
|
807
|
-
|
|
808
|
-
result
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
805
|
+
// A throw before the slot streams reaches the owning entry's boundary.
|
|
806
|
+
try {
|
|
807
|
+
if (hasLoadingFallback) {
|
|
808
|
+
const result = run();
|
|
809
|
+
if (result instanceof Promise) {
|
|
810
|
+
warnOnStreamedResponse(result, parallelId);
|
|
811
|
+
result.finally(doneHandler).catch(() => {});
|
|
812
|
+
component = trackStreamedHandler(deps, result, {
|
|
813
|
+
boundaryEntry,
|
|
814
|
+
segmentId: parallelId,
|
|
815
|
+
segmentType: "parallel",
|
|
816
|
+
context,
|
|
817
|
+
routeKey,
|
|
818
|
+
params,
|
|
819
|
+
isPartial: reportsPartial(pass),
|
|
820
|
+
recover: !pass.throwOnError,
|
|
821
|
+
});
|
|
822
|
+
} else {
|
|
823
|
+
doneHandler();
|
|
824
|
+
component = result as ReactNode;
|
|
825
|
+
}
|
|
818
826
|
} else {
|
|
827
|
+
// A static value (a Promise included) goes to render as is.
|
|
828
|
+
component = typeof handler === "function" ? await run() : handler;
|
|
819
829
|
doneHandler();
|
|
820
|
-
component = result as ReactNode;
|
|
821
830
|
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
component = typeof handler === "function" ? await run() : handler;
|
|
825
|
-
doneHandler();
|
|
831
|
+
} catch (error) {
|
|
832
|
+
throw noteThrower(error, parallelId, "parallel");
|
|
826
833
|
}
|
|
827
834
|
}
|
|
828
835
|
}
|
|
@@ -1180,6 +1187,7 @@ async function resolveRouteComponent<TEnv>(
|
|
|
1180
1187
|
context,
|
|
1181
1188
|
routeKey,
|
|
1182
1189
|
params,
|
|
1190
|
+
isPartial: reportsPartial(pass),
|
|
1183
1191
|
recover: !pass.throwOnError,
|
|
1184
1192
|
}),
|
|
1185
1193
|
};
|
|
@@ -1504,6 +1512,9 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
1504
1512
|
const doneHandler = track(`handler:${orphan.id}`, 2);
|
|
1505
1513
|
try {
|
|
1506
1514
|
component = await resolveLayoutComponent(orphan, context);
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
// Its owning entry's boundary catches it.
|
|
1517
|
+
throw noteThrower(error, orphan.shortCode, orphan.type);
|
|
1507
1518
|
} finally {
|
|
1508
1519
|
internal._currentSegmentId = pinned;
|
|
1509
1520
|
}
|
|
@@ -1598,6 +1609,15 @@ export interface RevalidatingWalkOptions {
|
|
|
1598
1609
|
readonly chain?: readonly EntryData[];
|
|
1599
1610
|
}
|
|
1600
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* The onError `isPartial` of an error the walk catches. FRESH: a full match
|
|
1614
|
+
* reports it unset. A re-render a navigation started reports it true
|
|
1615
|
+
* (partialRequest).
|
|
1616
|
+
*/
|
|
1617
|
+
function reportsPartial(pass: ResolutionPass<any>): true | undefined {
|
|
1618
|
+
return pass.client !== FRESH || pass.partialRequest ? true : undefined;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1601
1621
|
/** An entry's error or not-found segment, as its whole result. */
|
|
1602
1622
|
function boundaryResult(seg: ResolvedSegment): SegmentRevalidationResult {
|
|
1603
1623
|
return { segments: [seg], matchedIds: [seg.id] };
|
|
@@ -1633,14 +1653,12 @@ export async function resolveAllSegments<TEnv>(
|
|
|
1633
1653
|
try {
|
|
1634
1654
|
telemetry = getRouterContext()?.telemetry;
|
|
1635
1655
|
} catch {}
|
|
1636
|
-
//
|
|
1637
|
-
// build-time pass rethrows it (throwOnError). A re-render a navigation
|
|
1638
|
-
// started reports it with isPartial (partialRequest).
|
|
1656
|
+
// A build-time pass rethrows an entry's error (throwOnError).
|
|
1639
1657
|
const report = {
|
|
1640
1658
|
request,
|
|
1641
1659
|
url: context.url,
|
|
1642
1660
|
routeKey,
|
|
1643
|
-
isPartial: pass
|
|
1661
|
+
isPartial: reportsPartial(pass),
|
|
1644
1662
|
telemetry,
|
|
1645
1663
|
};
|
|
1646
1664
|
const fillMatch = {
|
|
@@ -1650,16 +1668,19 @@ export async function resolveAllSegments<TEnv>(
|
|
|
1650
1668
|
};
|
|
1651
1669
|
|
|
1652
1670
|
const boundary = pass.actionContext?.boundary;
|
|
1653
|
-
const
|
|
1671
|
+
const cutId = boundary?.id ?? pass.heldBoundary;
|
|
1672
|
+
let cut = false;
|
|
1654
1673
|
for (const entry of entries) {
|
|
1655
|
-
if (entry.shortCode ===
|
|
1674
|
+
if (entry.shortCode === cutId) {
|
|
1656
1675
|
// The outlet's loader ids stay claimed. _handlerRan makes the browser
|
|
1657
|
-
// clear the outlet's handle values.
|
|
1658
|
-
|
|
1659
|
-
|
|
1676
|
+
// clear the outlet's handle values. A held boundary sends no segment:
|
|
1677
|
+
// the client keeps its own, with its handle values.
|
|
1678
|
+
if (boundary) allSegments.push({ ...boundary, _handlerRan: true });
|
|
1679
|
+
matchedIds.push(cutId);
|
|
1660
1680
|
entry.loader?.forEach((loaderEntry, i) =>
|
|
1661
1681
|
matchedIds.push(`${entry.shortCode}D${i}.${loaderEntry.loader.$$id}`),
|
|
1662
1682
|
);
|
|
1683
|
+
cut = true;
|
|
1663
1684
|
break;
|
|
1664
1685
|
}
|
|
1665
1686
|
if (entry.type === "route" && interceptResult) {
|
|
@@ -1721,6 +1742,14 @@ export async function resolveAllSegments<TEnv>(
|
|
|
1721
1742
|
}
|
|
1722
1743
|
}
|
|
1723
1744
|
|
|
1745
|
+
// A held boundary that names no entry (HMR rewrote the routes since the
|
|
1746
|
+
// page rendered): the page renders whole, as without one.
|
|
1747
|
+
if (pass.heldBoundary && !cut && process.env.NODE_ENV !== "production") {
|
|
1748
|
+
console.warn(
|
|
1749
|
+
`[@rangojs/router] A refetch held the error boundary "${pass.heldBoundary}", ` +
|
|
1750
|
+
`which is not on the route's chain (${routeKey}): the page renders whole.`,
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1724
1753
|
deliverStartedLoaders(context, allSegments);
|
|
1725
1754
|
return { segments: allSegments, matchedIds };
|
|
1726
1755
|
}
|
|
@@ -44,6 +44,8 @@ export interface StreamedHandlerOptions<TEnv> {
|
|
|
44
44
|
context: HandlerContext<any, TEnv>;
|
|
45
45
|
routeKey?: string;
|
|
46
46
|
params: Record<string, string>;
|
|
47
|
+
/** onError's `isPartial`, as the walk reports a sync handler's error. */
|
|
48
|
+
isPartial: true | undefined;
|
|
47
49
|
/** False where the rejection must reach the caller (prerender, intercept background re-render). */
|
|
48
50
|
recover: boolean;
|
|
49
51
|
}
|
|
@@ -64,6 +66,7 @@ export function trackStreamedHandler<TEnv>(
|
|
|
64
66
|
const tracked = deps.trackHandler(result as Promise<ReactNode>, {
|
|
65
67
|
segmentId,
|
|
66
68
|
segmentType,
|
|
69
|
+
isPartial: options.isPartial,
|
|
67
70
|
});
|
|
68
71
|
observeStreamedHandler(
|
|
69
72
|
tracked,
|
package/src/router/types.ts
CHANGED
|
@@ -57,7 +57,6 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
57
57
|
params?: Record<string, string>;
|
|
58
58
|
env?: TEnv;
|
|
59
59
|
isPartial?: boolean;
|
|
60
|
-
requestStartTime?: number;
|
|
61
60
|
},
|
|
62
61
|
/** An `ssr: false` loader on a document or capture render. */
|
|
63
62
|
useDefaultErrorBoundary?: boolean,
|
|
@@ -67,6 +66,7 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
67
66
|
errorContext?: {
|
|
68
67
|
segmentId?: string;
|
|
69
68
|
segmentType?: string;
|
|
69
|
+
isPartial?: boolean;
|
|
70
70
|
},
|
|
71
71
|
) => Promise<T>;
|
|
72
72
|
findNearestErrorBoundary: (
|
package/src/router.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { createHandleStore, type HandleStore } from "./server/handle-store.js";
|
|
|
44
44
|
import {
|
|
45
45
|
getRequestContext,
|
|
46
46
|
_getRequestContext,
|
|
47
|
+
reportedErrorsOf,
|
|
47
48
|
} from "./server/request-context.js";
|
|
48
49
|
import type {
|
|
49
50
|
ErrorPhase,
|
|
@@ -315,7 +316,8 @@ export function createRouter<TEnv = any>(
|
|
|
315
316
|
* Wrapper for invokeOnError that binds the router's onError callback.
|
|
316
317
|
* Uses the shared utility from router/error-handling.ts for consistent behavior.
|
|
317
318
|
*
|
|
318
|
-
* Deduplicates via per-request WeakSet stored on the ALS request context
|
|
319
|
+
* Deduplicates via per-request WeakSet stored on the ALS request context
|
|
320
|
+
* (background work keeps its own, reportedErrorsOf).
|
|
319
321
|
* A closure-level WeakSet would silently swallow errors if the same object
|
|
320
322
|
* instance is thrown across separate requests (e.g. a singleton error).
|
|
321
323
|
*/
|
|
@@ -325,7 +327,8 @@ export function createRouter<TEnv = any>(
|
|
|
325
327
|
context: Parameters<typeof invokeOnError<TEnv>>[3],
|
|
326
328
|
): void {
|
|
327
329
|
if (error != null && typeof error === "object") {
|
|
328
|
-
const
|
|
330
|
+
const reqCtx = _getRequestContext();
|
|
331
|
+
const reportedErrors = reqCtx && reportedErrorsOf(reqCtx);
|
|
329
332
|
if (reportedErrors) {
|
|
330
333
|
if (reportedErrors.has(error)) return;
|
|
331
334
|
reportedErrors.add(error);
|
|
@@ -488,6 +491,7 @@ export function createRouter<TEnv = any>(
|
|
|
488
491
|
errorContext?: {
|
|
489
492
|
segmentId?: string;
|
|
490
493
|
segmentType?: string;
|
|
494
|
+
isPartial?: boolean;
|
|
491
495
|
},
|
|
492
496
|
): Promise<T> => {
|
|
493
497
|
// One ALS read serves both the store lookup and the onError closure.
|
|
@@ -520,6 +524,7 @@ export function createRouter<TEnv = any>(
|
|
|
520
524
|
env: reqCtx.env as TEnv,
|
|
521
525
|
segmentId: errorContext?.segmentId,
|
|
522
526
|
segmentType: errorContext?.segmentType as any,
|
|
527
|
+
isPartial: errorContext?.isPartial,
|
|
523
528
|
handledByBoundary: true,
|
|
524
529
|
});
|
|
525
530
|
});
|
|
@@ -542,7 +547,6 @@ export function createRouter<TEnv = any>(
|
|
|
542
547
|
params?: Record<string, string>;
|
|
543
548
|
env?: TEnv;
|
|
544
549
|
isPartial?: boolean;
|
|
545
|
-
requestStartTime?: number;
|
|
546
550
|
},
|
|
547
551
|
useDefaultErrorBoundary?: boolean,
|
|
548
552
|
): Promise<LoaderDataResult<T>> {
|
|
@@ -592,7 +596,6 @@ export function createRouter<TEnv = any>(
|
|
|
592
596
|
env: errorContext.env,
|
|
593
597
|
isPartial: errorContext.isPartial,
|
|
594
598
|
handledByBoundary: ctx.handledByBoundary,
|
|
595
|
-
requestStartTime: errorContext.requestStartTime,
|
|
596
599
|
});
|
|
597
600
|
if (emitTelemetry) {
|
|
598
601
|
const errorObj =
|
|
@@ -22,6 +22,7 @@ import type { MiddlewareEntry } from "../router/middleware-types.js";
|
|
|
22
22
|
import {
|
|
23
23
|
_getRequestContext,
|
|
24
24
|
getRequestContext,
|
|
25
|
+
reportedErrorsOf,
|
|
25
26
|
setRequestContextParams,
|
|
26
27
|
type RequestContext,
|
|
27
28
|
} from "../server/request-context.js";
|
|
@@ -158,8 +159,9 @@ export function createErrorReporter<TEnv>(router: {
|
|
|
158
159
|
// case — the error is from a cancelled stream, not a real failure.
|
|
159
160
|
const reqCtx = _getRequestContext();
|
|
160
161
|
if (error != null && typeof error === "object" && reqCtx) {
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const reportedErrors = reportedErrorsOf(reqCtx);
|
|
163
|
+
if (reportedErrors.has(error)) return;
|
|
164
|
+
reportedErrors.add(error);
|
|
163
165
|
}
|
|
164
166
|
invokeOnError(router.onError, error, phase, context, "RSC");
|
|
165
167
|
};
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
|
48
48
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
49
49
|
import type { TraceSpan } from "../router/tracing.js";
|
|
50
50
|
import {
|
|
51
|
+
runAsBackgroundWork,
|
|
51
52
|
runWithRequestContext,
|
|
52
53
|
setRequestContextParams,
|
|
53
54
|
UNTRACKED_BACKGROUND_TASK,
|
|
@@ -1279,69 +1280,73 @@ export function planShellCapture(input: ShellCaptureInput): void {
|
|
|
1279
1280
|
// (observed in production: ~24s of zero I/O before capture start).
|
|
1280
1281
|
// observePhase reads tracing off the ALS context captured when runBackground
|
|
1281
1282
|
// registered the task — the foreground request context, tracing intact.
|
|
1283
|
+
// onError marks every error of the capture `background`, the task's own
|
|
1284
|
+
// failure (captureTask's catch) included.
|
|
1282
1285
|
const serializedTask = async () => {
|
|
1283
|
-
await
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
(
|
|
1286
|
+
await runAsBackgroundWork(reqCtx, () =>
|
|
1287
|
+
observePhase(PHASES.background("shell-capture"), async (span) => {
|
|
1288
|
+
span.setAttribute("rango.shell_key", key);
|
|
1289
|
+
// A production page can enqueue several navigation-only captures through
|
|
1290
|
+
// viewport prefetching. Let a later document MISS overtake that queued
|
|
1291
|
+
// speculative work so it can become a shell HIT before the 15s queue
|
|
1292
|
+
// budget expires. Never preempts the active capture. Priority and the
|
|
1293
|
+
// backlog ahead at enqueue ride the span (and the skip event below), so
|
|
1294
|
+
// a skip-queue-timeout diagnoses itself instead of needing a trace dive.
|
|
1295
|
+
const queuePriority = descriptor.navigationOnly
|
|
1296
|
+
? ("navigation" as const)
|
|
1297
|
+
: ("document" as const);
|
|
1298
|
+
const depths = captureQueueDepths();
|
|
1299
|
+
const queueAhead =
|
|
1300
|
+
(depths.running ? 1 : 0) +
|
|
1301
|
+
depths.document +
|
|
1302
|
+
(queuePriority === "navigation" ? depths.navigation : 0);
|
|
1303
|
+
span.setAttribute("rango.background.queue_priority", queuePriority);
|
|
1304
|
+
span.setAttribute("rango.background.queue_ahead", queueAhead);
|
|
1305
|
+
const queueStart = performance.now();
|
|
1306
|
+
try {
|
|
1307
|
+
await enqueueSerializedCapture(
|
|
1308
|
+
() => {
|
|
1309
|
+
span.setAttribute(
|
|
1310
|
+
"rango.background.queue_wait_ms",
|
|
1311
|
+
Math.round(performance.now() - queueStart),
|
|
1312
|
+
);
|
|
1313
|
+
return captureTask(span);
|
|
1314
|
+
},
|
|
1315
|
+
{ priority: queuePriority },
|
|
1316
|
+
);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error instanceof CaptureQueueFullError) {
|
|
1319
|
+
releaseCaptureGuard(key, guardToken);
|
|
1320
|
+
span.setAttribute("rango.background.outcome", "skip-capacity");
|
|
1321
|
+
publishCaptureDebugEvent(descriptor, {
|
|
1322
|
+
key,
|
|
1323
|
+
outcome: "skip-capacity",
|
|
1324
|
+
});
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (error instanceof CaptureQueueWaitTimeoutError) {
|
|
1328
|
+
// Dropped unrun after waiting past the queue budget: no backoff (the
|
|
1329
|
+
// route is not doomed, the isolate was busy) — a later request
|
|
1330
|
+
// re-probes the key.
|
|
1331
|
+
releaseCaptureGuard(key, guardToken);
|
|
1332
|
+
span.setAttribute("rango.background.outcome", "skip-queue-timeout");
|
|
1305
1333
|
span.setAttribute(
|
|
1306
1334
|
"rango.background.queue_wait_ms",
|
|
1307
|
-
Math.round(
|
|
1335
|
+
Math.round(error.waitedMs),
|
|
1308
1336
|
);
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
outcome: "skip-capacity",
|
|
1320
|
-
});
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
1323
|
-
if (error instanceof CaptureQueueWaitTimeoutError) {
|
|
1324
|
-
// Dropped unrun after waiting past the queue budget: no backoff (the
|
|
1325
|
-
// route is not doomed, the isolate was busy) — a later request
|
|
1326
|
-
// re-probes the key.
|
|
1327
|
-
releaseCaptureGuard(key, guardToken);
|
|
1328
|
-
span.setAttribute("rango.background.outcome", "skip-queue-timeout");
|
|
1329
|
-
span.setAttribute(
|
|
1330
|
-
"rango.background.queue_wait_ms",
|
|
1331
|
-
Math.round(error.waitedMs),
|
|
1332
|
-
);
|
|
1333
|
-
publishCaptureDebugEvent(descriptor, {
|
|
1334
|
-
key,
|
|
1335
|
-
outcome: "skip-queue-timeout",
|
|
1336
|
-
queueWaitMs: Math.round(error.waitedMs),
|
|
1337
|
-
queuePriority,
|
|
1338
|
-
queueAhead,
|
|
1339
|
-
});
|
|
1340
|
-
return;
|
|
1337
|
+
publishCaptureDebugEvent(descriptor, {
|
|
1338
|
+
key,
|
|
1339
|
+
outcome: "skip-queue-timeout",
|
|
1340
|
+
queueWaitMs: Math.round(error.waitedMs),
|
|
1341
|
+
queuePriority,
|
|
1342
|
+
queueAhead,
|
|
1343
|
+
});
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
throw error;
|
|
1341
1347
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
});
|
|
1348
|
+
}),
|
|
1349
|
+
);
|
|
1345
1350
|
};
|
|
1346
1351
|
// The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
|
|
1347
1352
|
// capture drains that list before rendering (the write-barrier ordering edge),
|