@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
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
import { KEEP_CACHE_HEADER } from "./cookie-name.js";
|
|
16
16
|
import { createNavigationTransaction } from "./navigation-transaction.js";
|
|
17
17
|
import {
|
|
18
|
+
actionBoundaries,
|
|
19
|
+
actionBoundaryAt,
|
|
20
|
+
idsAboveBoundary,
|
|
18
21
|
reconcileSegments,
|
|
19
22
|
reconcileErrorSegments,
|
|
20
23
|
} from "./segment-reconciler.js";
|
|
@@ -206,6 +209,8 @@ export function createServerActionBridge(
|
|
|
206
209
|
actionId?: string;
|
|
207
210
|
/** transition({ when }) `action` fields for the refetch commit. */
|
|
208
211
|
action?: TransitionWhenActionInput;
|
|
212
|
+
/** The follow-up's held error boundary (UpdateMode `action`). */
|
|
213
|
+
boundary?: string;
|
|
209
214
|
};
|
|
210
215
|
|
|
211
216
|
/** The follow-up's transition({ when }) fields: the last owing action's. */
|
|
@@ -255,13 +260,13 @@ export function createServerActionBridge(
|
|
|
255
260
|
let followUpsStarted = 0;
|
|
256
261
|
/** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
|
|
257
262
|
let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
|
|
258
|
-
|
|
259
263
|
/**
|
|
260
264
|
* The follow-up revalidation: the rules are R1-R7 in
|
|
261
265
|
* docs/internal/execution-model.md. Once the batch's last action exited,
|
|
262
266
|
* the page on screen that missed a write is refetched once, whole (no
|
|
263
|
-
* segments held, so every loader re-runs
|
|
264
|
-
*
|
|
267
|
+
* segments held, so every loader re-runs; above an action's boundary, see
|
|
268
|
+
* below), in the action lane (storeOnly, skipLoadingState:
|
|
269
|
+
* useNavigation() stays idle).
|
|
265
270
|
*
|
|
266
271
|
* - Never over an intercept: a request holding no segments drops the page
|
|
267
272
|
* under it (match-result.ts buildMatchResult). Checked when it would run,
|
|
@@ -271,6 +276,10 @@ export function createServerActionBridge(
|
|
|
271
276
|
* keeps its error on screen, and the debt is dropped.
|
|
272
277
|
* - Runs again when it is left before its commit (a navigation, or an action
|
|
273
278
|
* that started: leaveFollowUp), for the page then on screen.
|
|
279
|
+
* - Holds the action error boundary the page shows (actionBoundaryAt): it
|
|
280
|
+
* is that action's outcome, which no GET reproduces, so the server
|
|
281
|
+
* renders every segment above it and nothing at or below it. With
|
|
282
|
+
* nothing above it (the page's first segment), the debt is dropped.
|
|
274
283
|
*/
|
|
275
284
|
function followUp(): void {
|
|
276
285
|
if (!owed || isActionFenceActive()) return;
|
|
@@ -285,8 +294,16 @@ export function createServerActionBridge(
|
|
|
285
294
|
const due = owed;
|
|
286
295
|
owed = undefined;
|
|
287
296
|
if (store.getInterceptSourceUrl()) return;
|
|
297
|
+
// The deepest action boundary the page shows: none (-1) refetches the
|
|
298
|
+
// page whole, the first segment (0) leaves nothing above it.
|
|
299
|
+
const shown =
|
|
300
|
+
store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
|
|
301
|
+
const at = actionBoundaryAt(shown);
|
|
302
|
+
if (at === 0) return;
|
|
303
|
+
// Not on `due`: a run owed again (owed ??= due) looks again.
|
|
304
|
+
const held = { ...due, boundary: shown[at]?.id };
|
|
288
305
|
const generation = ++followUpsStarted;
|
|
289
|
-
refetchRoute(
|
|
306
|
+
refetchRoute(held, {
|
|
290
307
|
started: (handle) => (followUpRunning = { handle, due }),
|
|
291
308
|
ended: (left) => {
|
|
292
309
|
if (generation !== followUpsStarted) return;
|
|
@@ -359,6 +376,7 @@ export function createServerActionBridge(
|
|
|
359
376
|
...(src ? { interceptSourceUrl: src } : {}),
|
|
360
377
|
...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
|
|
361
378
|
...(opts.action ? { action: opts.action } : {}),
|
|
379
|
+
boundary: opts.boundary,
|
|
362
380
|
},
|
|
363
381
|
);
|
|
364
382
|
return { started: true, error };
|
|
@@ -794,11 +812,15 @@ export function createServerActionBridge(
|
|
|
794
812
|
// Cache (and bump the nav instance) BEFORE the UI update so a deferred
|
|
795
813
|
// handle pushed by the error-boundary render still applies — see the
|
|
796
814
|
// "normal" case below for why caching after onUpdate dropped it. The
|
|
797
|
-
//
|
|
798
|
-
//
|
|
815
|
+
// action's boundary is the last error segment: the server's cut stops
|
|
816
|
+
// there (segment-resolution/revalidation.ts resolveAllSegments). The
|
|
817
|
+
// page holds what is above it, the loaders the response re-ran
|
|
818
|
+
// included, and no error segment.
|
|
819
|
+
const errors = segments.filter((s) => s.type === "error");
|
|
820
|
+
const boundary = errors[errors.length - 1];
|
|
799
821
|
store.setSegmentIds(
|
|
800
|
-
segmentState.currentSegmentIds.filter(
|
|
801
|
-
(id) => !
|
|
822
|
+
idsAboveBoundary(segmentState.currentSegmentIds, boundary?.id).filter(
|
|
823
|
+
(id) => !errors.some((s) => s.id === id),
|
|
802
824
|
),
|
|
803
825
|
);
|
|
804
826
|
const currentHandleData = liveHandleDataForCache(
|
|
@@ -812,6 +834,7 @@ export function createServerActionBridge(
|
|
|
812
834
|
currentHandleData,
|
|
813
835
|
);
|
|
814
836
|
store.rememberDisplayedEntry();
|
|
837
|
+
if (boundary) actionBoundaries.add(boundary);
|
|
815
838
|
|
|
816
839
|
// Update UI with error boundary.
|
|
817
840
|
if (process.env.NODE_ENV !== "production") auditTreeCause("action");
|
|
@@ -912,6 +935,10 @@ export function createServerActionBridge(
|
|
|
912
935
|
metadata?.locationState,
|
|
913
936
|
eventController,
|
|
914
937
|
);
|
|
938
|
+
// A success on the page supersedes the action boundary it shows
|
|
939
|
+
// (R4): a repair that stands in for this commit, direct or owed to
|
|
940
|
+
// the batch's end, holds none.
|
|
941
|
+
for (const s of cachedSegments) actionBoundaries.delete(s);
|
|
915
942
|
}
|
|
916
943
|
whenAction = { ...whenAction, result: returnData };
|
|
917
944
|
|
package/src/browser/types.ts
CHANGED
|
@@ -201,13 +201,27 @@ export interface InflightAction {
|
|
|
201
201
|
* @internal This type is an implementation detail. Use PublicNavigationState instead.
|
|
202
202
|
*/
|
|
203
203
|
export interface NavigationState {
|
|
204
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Lifecycle state: `"loading"` while a navigation is in flight and while a
|
|
206
|
+
* server action runs, `"idle"` otherwise
|
|
207
|
+
*/
|
|
205
208
|
state: "idle" | "loading";
|
|
206
209
|
|
|
207
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Whether RSC data is currently streaming: a navigation's response, or a
|
|
212
|
+
* server action's response (initial load, navigation or action)
|
|
213
|
+
*/
|
|
208
214
|
isStreaming: boolean;
|
|
209
215
|
|
|
210
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* True while a navigation is in flight (fetching or streaming, before its
|
|
218
|
+
* commit); false during a server action, whose `state` is still `"loading"`.
|
|
219
|
+
* Drive a navigation progress bar from this and an action spinner from
|
|
220
|
+
* `state`.
|
|
221
|
+
*/
|
|
222
|
+
isNavigating: boolean;
|
|
223
|
+
|
|
224
|
+
/** The page on screen; moves when the next page shows (`pendingUrl` is the page being fetched) */
|
|
211
225
|
location: NavigationLocation;
|
|
212
226
|
|
|
213
227
|
/** URL being navigated to (null when idle) */
|
|
@@ -239,7 +253,11 @@ export type ActionLifecycleState = "idle" | "loading" | "streaming";
|
|
|
239
253
|
* @internal This type is an implementation detail and may change without notice.
|
|
240
254
|
*/
|
|
241
255
|
export interface TrackedActionState {
|
|
242
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Current lifecycle state of the action. Once the call settles it is
|
|
258
|
+
* "idle" and the rest of the call's state is kept until the action's next
|
|
259
|
+
* call.
|
|
260
|
+
*/
|
|
243
261
|
state: ActionLifecycleState;
|
|
244
262
|
|
|
245
263
|
/** Server action function ID (e.g., "addToCart") */
|
|
@@ -251,13 +269,13 @@ export interface TrackedActionState {
|
|
|
251
269
|
/** Error if action failed */
|
|
252
270
|
error: unknown | null;
|
|
253
271
|
|
|
254
|
-
/** Result data from the action
|
|
272
|
+
/** Result data from the action */
|
|
255
273
|
result: unknown | null;
|
|
256
274
|
|
|
257
275
|
/**
|
|
258
276
|
* The error of a loader that threw while it re-ran after this successful
|
|
259
|
-
* action (the first, when several did). The loader also throws at its
|
|
260
|
-
*
|
|
277
|
+
* action (the first, when several did). The loader also throws at its useLoader read, where the nearest
|
|
278
|
+
* error boundary catches it.
|
|
261
279
|
*/
|
|
262
280
|
revalidationError: unknown | null;
|
|
263
281
|
}
|
|
@@ -505,6 +523,8 @@ export interface NavigateOptionsInternal extends Omit<
|
|
|
505
523
|
_skipCache?: boolean;
|
|
506
524
|
/** Server redirects already followed by this navigation chain (0 for a user navigation) */
|
|
507
525
|
_redirectHops?: number;
|
|
526
|
+
/** The URL a redirected navigation chain started from: a link to it stays pending (useLinkStatus) */
|
|
527
|
+
_redirectFrom?: string;
|
|
508
528
|
}
|
|
509
529
|
|
|
510
530
|
/**
|
|
@@ -718,6 +738,8 @@ export interface FetchPartialOptions {
|
|
|
718
738
|
signal?: AbortSignal;
|
|
719
739
|
/** If true, this is a stale cache revalidation request - server should force revalidators */
|
|
720
740
|
staleRevalidation?: boolean;
|
|
741
|
+
/** Sent as `_rsc_boundary` (wire-params.ts WIRE.boundary). */
|
|
742
|
+
boundary?: string;
|
|
721
743
|
interceptSourceUrl?: string;
|
|
722
744
|
/** RSC version for cache invalidation detection */
|
|
723
745
|
version?: string;
|
|
@@ -803,7 +825,8 @@ export interface ServerActionBridgeConfig {
|
|
|
803
825
|
*/
|
|
804
826
|
export interface NavigationBridge {
|
|
805
827
|
navigate(url: string, options?: NavigateOptionsInternal): Promise<void>;
|
|
806
|
-
|
|
828
|
+
/** `crossTab`: another tab's write; an action's error boundary stays. */
|
|
829
|
+
refresh(crossTab?: boolean): Promise<void>;
|
|
807
830
|
/** `event`: the popstate event; a traversal the browser animated itself (hasUAVisualTransition) is not animated again. */
|
|
808
831
|
handlePopstate(event?: PopStateEvent): Promise<void>;
|
|
809
832
|
registerLinkInterception(): () => void;
|
|
@@ -13,6 +13,7 @@ export interface PartialRequestInit {
|
|
|
13
13
|
*/
|
|
14
14
|
emptySegments: "send" | "omit";
|
|
15
15
|
stale?: boolean;
|
|
16
|
+
boundary?: string;
|
|
16
17
|
version?: string;
|
|
17
18
|
routerId?: string;
|
|
18
19
|
rewriteTarget?: string;
|
|
@@ -33,6 +34,7 @@ export function writePartialParams(url: URL, init: PartialRequestInit): URL {
|
|
|
33
34
|
params.set(WIRE.segments, init.segmentIds.join(","));
|
|
34
35
|
}
|
|
35
36
|
if (init.stale) params.set(WIRE.stale, "true");
|
|
37
|
+
if (init.boundary) params.set(WIRE.boundary, init.boundary);
|
|
36
38
|
if (init.version) params.set(WIRE.version, init.version);
|
|
37
39
|
if (init.routerId) params.set(WIRE.routerId, init.routerId);
|
|
38
40
|
if (init.rewriteTarget) params.set(WIRE.rewriteTarget, init.rewriteTarget);
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../deps/rsc-client.js";
|
|
23
23
|
import {
|
|
24
24
|
getRequestContext,
|
|
25
|
+
runAsBackgroundWork,
|
|
25
26
|
type RequestContext,
|
|
26
27
|
} from "../server/request-context.js";
|
|
27
28
|
import { isUnderTestRunner } from "../runtime-env.js";
|
|
@@ -75,7 +76,6 @@ import {
|
|
|
75
76
|
executionStart,
|
|
76
77
|
invalidatedSince,
|
|
77
78
|
predatesInvalidation,
|
|
78
|
-
runAsBackgroundRefresh,
|
|
79
79
|
type ExecutionStart,
|
|
80
80
|
} from "./tag-invalidation.js";
|
|
81
81
|
import type { CacheItemResult } from "./types.js";
|
|
@@ -727,22 +727,22 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
727
727
|
// user's value into the shared entry — with zero visibility to the
|
|
728
728
|
// concurrent foreground.
|
|
729
729
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
observePhase(
|
|
730
|
+
// Re-establish the request-context ALS so a "use cache" body that
|
|
731
|
+
// reads the ambient getRequestContext() (e.g.
|
|
732
|
+
// getRequestContext().env.ApiKey) resolves during the background
|
|
733
|
+
// revalidation instead of throwing "called outside of a request
|
|
734
|
+
// context". runAsBackgroundWork sets the store for fn's synchronous
|
|
735
|
+
// kickoff; its async continuations inherit it. No visitor made the
|
|
736
|
+
// invalidations it runs (no fresh-reads cookie), and onError marks
|
|
737
|
+
// its errors `background`, the failure caught below included.
|
|
738
|
+
//
|
|
739
|
+
// The span opens inside the request ALS and wraps the WHOLE task —
|
|
740
|
+
// fn kickoff through the store write — so its platform spans nest
|
|
741
|
+
// under rango.background and a slow or failing setItem is part of
|
|
742
|
+
// the traced revalidation, not an untraced tail.
|
|
743
|
+
await runAsBackgroundWork(requestCtx, async () => {
|
|
744
|
+
try {
|
|
745
|
+
await observePhase(
|
|
746
746
|
PHASES.background("use-cache-revalidation"),
|
|
747
747
|
async () => {
|
|
748
748
|
const refreshArgs = args.map(refreshView);
|
|
@@ -796,20 +796,17 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
796
796
|
noteWarmWrite(requestCtx, "item");
|
|
797
797
|
}
|
|
798
798
|
},
|
|
799
|
-
)
|
|
800
|
-
)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
requestCtx,
|
|
811
|
-
);
|
|
812
|
-
}
|
|
799
|
+
);
|
|
800
|
+
} catch (bgError) {
|
|
801
|
+
bgCapture?.stop();
|
|
802
|
+
reportCacheError(
|
|
803
|
+
bgError,
|
|
804
|
+
"stale-revalidation",
|
|
805
|
+
"[use cache] background revalidation failed",
|
|
806
|
+
requestCtx,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
813
810
|
}),
|
|
814
811
|
);
|
|
815
812
|
return result;
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
createRequestContext,
|
|
24
24
|
getRequestContext,
|
|
25
|
-
|
|
25
|
+
runAsBackgroundWork,
|
|
26
26
|
type RequestContext,
|
|
27
27
|
} from "../server/request-context.js";
|
|
28
28
|
import { deriveRenderPass } from "../server/render-pass.js";
|
|
@@ -378,24 +378,25 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
378
378
|
}),
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
381
|
+
// Re-establish the request-context ALS around the whole background
|
|
382
|
+
// task: next() re-runs the full handler pipeline, and on workerd a
|
|
383
|
+
// waitUntil task runs detached from the request's I/O context, so a
|
|
384
|
+
// handler/component reading getRequestContext() would otherwise
|
|
385
|
+
// throw. Same fix as the route-level/use-cache background
|
|
386
|
+
// revalidation paths. The rango.background span (kind=
|
|
387
|
+
// document-revalidation) wraps the re-render AND the store write so
|
|
388
|
+
// the task's spans — the re-run's own rango.* set and the drain/put
|
|
389
|
+
// platform spans — nest under one explanatory parent instead of
|
|
390
|
+
// dangling under the ended foreground phases. Running the put
|
|
391
|
+
// inside the ALS matches the MISS path, where putResponse already
|
|
392
|
+
// executes within the foreground request context. No visitor made
|
|
393
|
+
// the invalidations the re-run makes: the pass's fresh-reads
|
|
394
|
+
// writer is the sink's, which sets nothing. onError marks the
|
|
395
|
+
// re-run's errors `background`, the failure caught below included.
|
|
396
|
+
runBackground(requestCtx, () =>
|
|
397
|
+
runAsBackgroundWork(rerunCtx, async () => {
|
|
398
|
+
try {
|
|
399
|
+
await observePhase(
|
|
399
400
|
PHASES.background("document-revalidation"),
|
|
400
401
|
async () => {
|
|
401
402
|
const fresh = await next();
|
|
@@ -425,24 +426,20 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
425
426
|
`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
|
|
426
427
|
);
|
|
427
428
|
},
|
|
428
|
-
)
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
requestCtx,
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
429
|
+
);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
// The route now rewrites (route middleware): nothing to refresh
|
|
432
|
+
// this entry with.
|
|
433
|
+
if (isRewrite(error)) return;
|
|
434
|
+
reportCacheError(
|
|
435
|
+
error,
|
|
436
|
+
"cache-write",
|
|
437
|
+
"[DocumentCache] revalidation",
|
|
438
|
+
requestCtx,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
446
443
|
|
|
447
444
|
return drainOnResponseCallbacks(
|
|
448
445
|
addCacheStatusHeader(cached.response, "STALE"),
|
|
@@ -117,29 +117,31 @@ export async function readThroughItem<T>(
|
|
|
117
117
|
// span cover the write too (a slow or failing store write is part of
|
|
118
118
|
// the revalidation, not an untraced tail). The foreground miss below
|
|
119
119
|
// stays unwrapped: its context is already established.
|
|
120
|
+
// Reported inside the task, within wrapBackground's frame, so onError
|
|
121
|
+
// marks it `background`; rethrown so the span records the failure.
|
|
120
122
|
const revalidateTask = async () => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
try {
|
|
124
|
+
const fresh = await execute();
|
|
125
|
+
const serialized = await serialize(fresh);
|
|
126
|
+
if (serialized !== null) {
|
|
127
|
+
await setItem(key, serialized, storeOptions);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
reportCacheError(
|
|
131
|
+
error,
|
|
132
|
+
"stale-revalidation",
|
|
133
|
+
"[read-through] background revalidation",
|
|
134
|
+
host ?? undefined,
|
|
135
|
+
);
|
|
136
|
+
throw error;
|
|
125
137
|
}
|
|
126
138
|
};
|
|
127
139
|
runBackground(
|
|
128
140
|
host,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: revalidateTask());
|
|
134
|
-
} catch (error) {
|
|
135
|
-
reportCacheError(
|
|
136
|
-
error,
|
|
137
|
-
"stale-revalidation",
|
|
138
|
-
"[read-through] background revalidation",
|
|
139
|
-
host ?? undefined,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
},
|
|
141
|
+
() =>
|
|
142
|
+
(wrapBackground ? wrapBackground(revalidateTask) : revalidateTask())
|
|
143
|
+
// Reported above.
|
|
144
|
+
.catch(() => {}),
|
|
143
145
|
true,
|
|
144
146
|
);
|
|
145
147
|
return data;
|
|
@@ -36,11 +36,9 @@
|
|
|
36
36
|
* predatesInvalidation, and docs/design/caching.md "Read-your-own-writes".
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
40
39
|
import {
|
|
41
40
|
_getRequestContext,
|
|
42
|
-
|
|
43
|
-
type RequestContext,
|
|
41
|
+
isBackgroundWork,
|
|
44
42
|
} from "../server/request-context.js";
|
|
45
43
|
import { reportingAsync } from "./cache-error.js";
|
|
46
44
|
import { normalizeTags } from "./cache-tag.js";
|
|
@@ -134,20 +132,6 @@ function warnPartialTagStore(fn: string, incapable: number): void {
|
|
|
134
132
|
);
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
const backgroundRefresh = new AsyncLocalStorage<true>();
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Run a stale entry's background refresh on the visitor's request context: a
|
|
141
|
-
* "use cache" function's (cache-runtime.ts) or a cached loader's
|
|
142
|
-
* (loader-cache.ts). No visitor made the invalidations in it, so they set no
|
|
143
|
-
* fresh-reads cookie, as in a stale cache() HIT's re-render or a document
|
|
144
|
-
* cache entry's re-run, whose passes take a sink's no-op writer
|
|
145
|
-
* (server/render-pass.ts).
|
|
146
|
-
*/
|
|
147
|
-
export function runAsBackgroundRefresh<T>(ctx: RequestContext, fn: () => T): T {
|
|
148
|
-
return runWithRequestContext(ctx, () => backgroundRefresh.run(true, fn));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
135
|
/**
|
|
152
136
|
* Set the fresh-reads cookie on this request's response for as long as the
|
|
153
137
|
* invalidated stores' isolate memos can stay stale (isolate-tag-memo.ts), so
|
|
@@ -155,7 +139,8 @@ export function runAsBackgroundRefresh<T>(ctx: RequestContext, fn: () => T): T {
|
|
|
155
139
|
*/
|
|
156
140
|
function markFreshReads(stores: SegmentCacheStore[]): void {
|
|
157
141
|
const ctx = _getRequestContext();
|
|
158
|
-
|
|
142
|
+
// No visitor made an invalidation in background work (runAsBackgroundWork).
|
|
143
|
+
if (!ctx?._setFreshReadsCookie || isBackgroundWork()) return;
|
|
159
144
|
let windowMs = 0;
|
|
160
145
|
for (const store of stores) {
|
|
161
146
|
windowMs = Math.max(windowMs, store.freshReadsWindowMs ?? 0);
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import { createElement, type ReactNode } from "react";
|
|
9
9
|
import type { EntryData } from "../server/context.js";
|
|
10
10
|
import { runIdentityExempt } from "../cache/cache-exec-scope.js";
|
|
11
|
+
import {
|
|
12
|
+
_getRequestContext,
|
|
13
|
+
isBackgroundWork,
|
|
14
|
+
} from "../server/request-context.js";
|
|
11
15
|
import type {
|
|
12
16
|
ResolvedSegment,
|
|
13
17
|
ErrorInfo,
|
|
@@ -39,7 +43,7 @@ export interface InvokeOnErrorContext<TEnv = any> {
|
|
|
39
43
|
isPartial?: boolean;
|
|
40
44
|
handledByBoundary?: boolean;
|
|
41
45
|
metadata?: Record<string, unknown>;
|
|
42
|
-
/**
|
|
46
|
+
/** performance.now() at request entry; the request context's by default. */
|
|
43
47
|
requestStartTime?: number;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -50,6 +54,12 @@ export interface InvokeOnErrorContext<TEnv = any> {
|
|
|
50
54
|
* This is a shared utility used by both the router and RSC handler to ensure
|
|
51
55
|
* consistent error callback behavior across the codebase.
|
|
52
56
|
*
|
|
57
|
+
* `background` is set inside runAsBackgroundWork (server/request-context.ts),
|
|
58
|
+
* and `duration` and `env` come from the request context on the ALS
|
|
59
|
+
* (`_handlerStart`, `env`) when the call site leaves them out, so no call
|
|
60
|
+
* site has to know. A Prerender pass's ctx.env throws without buildEnv, so
|
|
61
|
+
* the walk never reads it eagerly.
|
|
62
|
+
*
|
|
53
63
|
* @param onError - The onError callback to invoke (may be undefined)
|
|
54
64
|
* @param error - The error that occurred
|
|
55
65
|
* @param phase - The phase where the error occurred
|
|
@@ -66,9 +76,10 @@ export function invokeOnError<TEnv = any>(
|
|
|
66
76
|
if (!onError) return;
|
|
67
77
|
|
|
68
78
|
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
const reqCtx = _getRequestContext();
|
|
80
|
+
const background = isBackgroundWork() || undefined;
|
|
81
|
+
const start = context.requestStartTime ?? reqCtx?._handlerStart;
|
|
82
|
+
const duration = start !== undefined ? performance.now() - start : undefined;
|
|
72
83
|
|
|
73
84
|
const errorContext: OnErrorContext<TEnv> = {
|
|
74
85
|
error: errorObj,
|
|
@@ -84,10 +95,11 @@ export function invokeOnError<TEnv = any>(
|
|
|
84
95
|
loaderName: context.loaderName,
|
|
85
96
|
middlewareId: context.middlewareId,
|
|
86
97
|
actionId: context.actionId,
|
|
87
|
-
env: context.env,
|
|
98
|
+
env: context.env ?? (reqCtx?.env as TEnv | undefined),
|
|
88
99
|
duration,
|
|
89
100
|
isPartial: context.isPartial,
|
|
90
|
-
|
|
101
|
+
background,
|
|
102
|
+
handledByBoundary: background ? false : context.handledByBoundary,
|
|
91
103
|
stack: errorObj.stack,
|
|
92
104
|
metadata: context.metadata,
|
|
93
105
|
};
|
|
@@ -164,15 +164,16 @@ export function findInterceptForRoute(
|
|
|
164
164
|
/**
|
|
165
165
|
* An intercept handler's throw or notFound() renders the declaring entry's
|
|
166
166
|
* boundary as the slot component (catchSegmentError, as for route handlers on
|
|
167
|
-
* partial navigations: 500/404 status, onError "handler"
|
|
168
|
-
* The background re-render (`background`) reports the
|
|
169
|
-
* then rethrows it, so a failed re-render aborts its
|
|
170
|
-
* storing the fallback.
|
|
167
|
+
* partial navigations: 500/404 status, onError "handler" naming the slot).
|
|
168
|
+
* Responses rethrow. The background re-render (`background`) reports the
|
|
169
|
+
* error the same way and then rethrows it, so a failed re-render aborts its
|
|
170
|
+
* cache write instead of storing the fallback.
|
|
171
171
|
*/
|
|
172
172
|
function renderInterceptHandlerError<TEnv>(
|
|
173
173
|
pass: ResolutionPass<TEnv>,
|
|
174
174
|
error: unknown,
|
|
175
175
|
parentEntry: EntryData,
|
|
176
|
+
slotSegmentId: string,
|
|
176
177
|
background: boolean | undefined,
|
|
177
178
|
): ReactNode {
|
|
178
179
|
if (error instanceof Response) throw error;
|
|
@@ -193,6 +194,7 @@ function renderInterceptHandlerError<TEnv>(
|
|
|
193
194
|
telemetry,
|
|
194
195
|
},
|
|
195
196
|
pass.context.pathname,
|
|
197
|
+
{ id: slotSegmentId, type: "parallel" },
|
|
196
198
|
);
|
|
197
199
|
if (background) throw error;
|
|
198
200
|
return component;
|
|
@@ -363,6 +365,7 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
363
365
|
pass,
|
|
364
366
|
error,
|
|
365
367
|
parentEntry,
|
|
368
|
+
slotSegmentId,
|
|
366
369
|
options?.background,
|
|
367
370
|
);
|
|
368
371
|
}
|
|
@@ -402,6 +405,7 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
402
405
|
context,
|
|
403
406
|
routeKey: interceptEntry.routeName,
|
|
404
407
|
params,
|
|
408
|
+
isPartial: true,
|
|
405
409
|
recover: !options?.background,
|
|
406
410
|
});
|
|
407
411
|
|
|
@@ -429,6 +433,7 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
429
433
|
pass,
|
|
430
434
|
error,
|
|
431
435
|
parentEntry,
|
|
436
|
+
slotSegmentId,
|
|
432
437
|
options?.background,
|
|
433
438
|
);
|
|
434
439
|
}
|
package/src/router/match-api.ts
CHANGED
|
@@ -514,6 +514,7 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
514
514
|
clientSegmentIds: nav.clientSegmentIds,
|
|
515
515
|
clientSegmentSet,
|
|
516
516
|
stale: nav.stale,
|
|
517
|
+
heldBoundary: nav.heldBoundary,
|
|
517
518
|
prevUrl: nav.prevUrl,
|
|
518
519
|
prevParams: nav.prevParams,
|
|
519
520
|
prevMatch: nav.prevMatch,
|
|
@@ -136,6 +136,8 @@ export interface MatchContext<TEnv = any> {
|
|
|
136
136
|
clientSegmentIds: string[];
|
|
137
137
|
clientSegmentSet: Set<string>;
|
|
138
138
|
stale: boolean;
|
|
139
|
+
/** NavigationSnapshot.heldBoundary: the walk stops above it. */
|
|
140
|
+
heldBoundary?: string;
|
|
139
141
|
|
|
140
142
|
// Previous navigation state
|
|
141
143
|
prevUrl: URL;
|
|
@@ -219,6 +221,7 @@ export function resolutionPassInput<TEnv>(
|
|
|
219
221
|
routeKey: ctx.routeKey,
|
|
220
222
|
actionContext: ctx.actionContext,
|
|
221
223
|
stale: ctx.stale,
|
|
224
|
+
heldBoundary: ctx.heldBoundary,
|
|
222
225
|
};
|
|
223
226
|
}
|
|
224
227
|
|