@rangojs/router 0.23.0 → 0.24.1
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 +4 -3
- package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
- package/dist/types/browser/partial-update.d.ts +6 -4
- package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
- package/dist/types/browser/react/context.d.ts +3 -2
- package/dist/types/browser/types.d.ts +5 -1
- package/dist/types/build/route-types/scan-filter.d.ts +7 -1
- package/dist/types/cache/document-cache-policy.d.ts +24 -0
- package/dist/types/cache/document-cache.d.ts +2 -10
- package/dist/types/client-urls/types.d.ts +8 -0
- package/dist/types/context-var.d.ts +12 -0
- package/dist/types/errors.d.ts +10 -0
- package/dist/types/handles/pushed-by.d.ts +8 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.rsc.d.ts +2 -0
- package/dist/types/response-utils.d.ts +7 -0
- package/dist/types/reverse.d.ts +8 -8
- package/dist/types/route-definition/rewrite.d.ts +56 -0
- package/dist/types/route-map-builder.d.ts +7 -2
- package/dist/types/router/basename.d.ts +7 -0
- package/dist/types/router/handler-context.d.ts +1 -5
- package/dist/types/router/internal-params.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +15 -0
- package/dist/types/router/revalidation.d.ts +1 -2
- package/dist/types/router/route-trie-builder.d.ts +10 -0
- package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
- package/dist/types/rsc/full-payload.d.ts +11 -0
- package/dist/types/rsc/handler.d.ts +33 -0
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/loader-fetch.d.ts +16 -0
- package/dist/types/rsc/rewrite-passes.d.ts +74 -0
- package/dist/types/rsc/ssr-setup.d.ts +3 -1
- package/dist/types/server/request-context.d.ts +39 -2
- package/dist/types/server/rewrite-trail.d.ts +49 -0
- package/dist/types/server/slot-accessors.d.ts +29 -0
- package/dist/types/testing/index.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +8 -0
- package/dist/types/testing/run-middleware.d.ts +20 -0
- package/dist/types/testing/serve-shell-request.d.ts +2 -1
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/request-scope.d.ts +7 -0
- package/dist/types/vite/discovery/state.d.ts +12 -9
- package/dist/vite/index.js +164 -103
- package/package.json +1 -1
- package/skills/caching/SKILL.md +12 -0
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +86 -1
- package/skills/hooks/SKILL.md +11 -6
- package/skills/hooks/data.md +3 -1
- package/skills/loader/SKILL.md +7 -1
- package/skills/middleware/SKILL.md +183 -2
- package/skills/ppr/SKILL.md +9 -0
- package/skills/rango/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +27 -0
- package/skills/testing/client-components.md +1 -0
- package/skills/testing/middleware.md +31 -10
- package/skills/testing/response-routes.md +1 -0
- package/skills/testing/serve-shell-request.md +19 -1
- package/skills/use-cache/SKILL.md +4 -2
- package/src/__augment-tests__/augmented.check.ts +20 -0
- package/src/browser/event-controller.ts +3 -0
- package/src/browser/merge-segment-loaders.ts +31 -18
- package/src/browser/navigation-bridge.ts +19 -3
- package/src/browser/partial-update.ts +27 -12
- package/src/browser/react/Link.tsx +7 -9
- package/src/browser/react/NavigationProvider.tsx +46 -26
- package/src/browser/react/context.ts +3 -2
- package/src/browser/react/use-router.ts +2 -4
- package/src/browser/server-action-bridge.ts +6 -0
- package/src/browser/types.ts +5 -1
- package/src/build/route-types/scan-filter.ts +11 -4
- package/src/cache/document-cache-policy.ts +131 -0
- package/src/cache/document-cache.ts +12 -119
- package/src/client-urls/client-root.tsx +3 -3
- package/src/client-urls/client-urls.ts +8 -1
- package/src/client-urls/types.ts +8 -0
- package/src/context-var.ts +32 -2
- package/src/errors.ts +19 -0
- package/src/handles/pushed-by.ts +26 -0
- package/src/index.rsc.ts +8 -0
- package/src/index.ts +7 -0
- package/src/response-utils.ts +19 -0
- package/src/reverse.ts +17 -14
- package/src/route-definition/redirect.ts +2 -15
- package/src/route-definition/rewrite.ts +146 -0
- package/src/route-map-builder.ts +14 -11
- package/src/router/basename.ts +20 -0
- package/src/router/handler-context.ts +3 -13
- package/src/router/intercept-resolution.ts +29 -12
- package/src/router/internal-params.ts +16 -0
- package/src/router/loader-resolution.ts +2 -0
- package/src/router/match-middleware/background-revalidation.ts +11 -9
- package/src/router/match-middleware/cache-lookup.ts +20 -15
- package/src/router/match-middleware/cache-store.ts +9 -11
- package/src/router/match-middleware/intercept-resolution.ts +4 -2
- package/src/router/middleware-types.ts +16 -0
- package/src/router/middleware.ts +127 -49
- package/src/router/revalidation.ts +1 -1
- package/src/router/route-trie-builder.ts +28 -1
- package/src/router/segment-resolution/loader-snapshot.ts +18 -4
- package/src/router/segment-resolution/mask-nested.ts +8 -4
- package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
- package/src/router/segment-resolution/revalidation.ts +57 -33
- package/src/rsc/full-payload.ts +37 -5
- package/src/rsc/handler.ts +82 -55
- package/src/rsc/helpers.ts +24 -0
- package/src/rsc/loader-fetch.ts +79 -24
- package/src/rsc/response-cache-serve.ts +18 -2
- package/src/rsc/response-route-handler.ts +4 -7
- package/src/rsc/rewrite-passes.ts +203 -0
- package/src/rsc/rsc-rendering.ts +4 -2
- package/src/rsc/ssr-setup.ts +11 -5
- package/src/server/cookie-parse.ts +36 -12
- package/src/server/cookie-store.ts +7 -3
- package/src/server/handle-store.ts +48 -17
- package/src/server/request-context.ts +154 -63
- package/src/server/request-headers.ts +4 -14
- package/src/server/rewrite-trail.ts +190 -0
- package/src/server/slot-accessors.ts +57 -0
- package/src/ssr/index.tsx +118 -16
- package/src/testing/dispatch.ts +138 -67
- package/src/testing/index.ts +1 -0
- package/src/testing/render-route.tsx +16 -3
- package/src/testing/run-loader.ts +2 -0
- package/src/testing/run-middleware.ts +56 -11
- package/src/testing/serve-shell-request.ts +15 -2
- package/src/thenable-status.ts +11 -0
- package/src/types/request-scope.ts +8 -0
- package/src/vite/discovery/state.ts +21 -13
- package/src/vite/plugins/expose-internal-ids.ts +2 -3
- package/src/vite/router-discovery.ts +29 -27
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
IS_BROWSER_DEBUG,
|
|
33
33
|
} from "./logging.js";
|
|
34
34
|
import { validatePayloadRedirect } from "./validate-redirect-origin.js";
|
|
35
|
-
import type { NavigationUpdate, RscMetadata } from "./types.js";
|
|
35
|
+
import type { HandleData, NavigationUpdate, RscMetadata } from "./types.js";
|
|
36
36
|
import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
|
|
37
37
|
import type { LoaderStore } from "../loader-store.js";
|
|
38
38
|
import { setActiveInterceptTargets } from "../client-urls/navigation.js";
|
|
@@ -200,9 +200,11 @@ export type UpdateMode =
|
|
|
200
200
|
* when we send cached segment IDs to the server - if the server returns empty diff,
|
|
201
201
|
* we use the same segments we told the server we have. */
|
|
202
202
|
targetCacheSegments?: ResolvedSegment[];
|
|
203
|
-
/**
|
|
204
|
-
*
|
|
205
|
-
|
|
203
|
+
/** The handle state of `targetCacheSegments`: the entry's pushes, and
|
|
204
|
+
* the live pushes of the page on screen for each segment taken from it.
|
|
205
|
+
* Read at commit; the page starts from it and the response's handles,
|
|
206
|
+
* when it has any, merge onto it (NavigationProvider). */
|
|
207
|
+
targetCacheHandleData?: () => HandleData;
|
|
206
208
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
207
209
|
interceptSourceUrl?: string;
|
|
208
210
|
/**
|
|
@@ -643,6 +645,16 @@ export function createPartialUpdater(
|
|
|
643
645
|
: undefined;
|
|
644
646
|
const cachedSegs = targetCache ?? getCurrentCachedSegments();
|
|
645
647
|
const cachedSegsSource = targetCache ? "history-cache" : "current-page";
|
|
648
|
+
// Rendered from the history cache, the page starts from the entry's
|
|
649
|
+
// pushes whether or not the response re-renders. Read before tx.commit():
|
|
650
|
+
// it records the controller's handle state (the document snapshot during
|
|
651
|
+
// the hydration window) in the entry it commits, which for a link to the
|
|
652
|
+
// page on screen's own URL is the entry this reads. Written over that
|
|
653
|
+
// record after it.
|
|
654
|
+
const readCachedHandleData =
|
|
655
|
+
targetCache && mode.type === "navigate"
|
|
656
|
+
? mode.targetCacheHandleData
|
|
657
|
+
: undefined;
|
|
646
658
|
if (IS_BROWSER_DEBUG) {
|
|
647
659
|
debugLog(
|
|
648
660
|
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
@@ -739,16 +751,16 @@ export function createPartialUpdater(
|
|
|
739
751
|
});
|
|
740
752
|
if (signal?.aborted) return;
|
|
741
753
|
|
|
754
|
+
const cachedHandleData = readCachedHandleData?.();
|
|
742
755
|
const { scroll: commitScroll } = tx.commit(
|
|
743
756
|
matchedIds,
|
|
744
757
|
existingSegments,
|
|
745
758
|
{ routeName: payload.metadata.routeName },
|
|
746
759
|
);
|
|
747
|
-
|
|
748
|
-
if (mode.targetCacheHandleData) {
|
|
760
|
+
if (cachedHandleData) {
|
|
749
761
|
store.updateCacheHandleData(
|
|
750
762
|
store.getHistoryKey(),
|
|
751
|
-
|
|
763
|
+
cachedHandleData,
|
|
752
764
|
);
|
|
753
765
|
}
|
|
754
766
|
|
|
@@ -756,10 +768,7 @@ export function createPartialUpdater(
|
|
|
756
768
|
payload.metadata!;
|
|
757
769
|
const cachedUpdate = {
|
|
758
770
|
root: newTree,
|
|
759
|
-
metadata: {
|
|
760
|
-
...metadataWithoutHandles,
|
|
761
|
-
cachedHandleData: mode.targetCacheHandleData,
|
|
762
|
-
},
|
|
771
|
+
metadata: { ...metadataWithoutHandles, cachedHandleData },
|
|
763
772
|
...(commitScroll && { scroll: commitScroll }),
|
|
764
773
|
};
|
|
765
774
|
|
|
@@ -1064,11 +1073,15 @@ export function createPartialUpdater(
|
|
|
1064
1073
|
}),
|
|
1065
1074
|
...(serverLocationState && { serverState: serverLocationState }),
|
|
1066
1075
|
};
|
|
1076
|
+
const cachedHandleData = readCachedHandleData?.();
|
|
1067
1077
|
const { scroll: navScroll } = tx.commit(
|
|
1068
1078
|
heldIds,
|
|
1069
1079
|
reconciled.segments,
|
|
1070
1080
|
overrides,
|
|
1071
1081
|
);
|
|
1082
|
+
if (cachedHandleData) {
|
|
1083
|
+
store.updateCacheHandleData(store.getHistoryKey(), cachedHandleData);
|
|
1084
|
+
}
|
|
1072
1085
|
const hasTransition = shouldStartViewTransition(
|
|
1073
1086
|
reconciled.segments,
|
|
1074
1087
|
gatedOff,
|
|
@@ -1118,7 +1131,9 @@ export function createPartialUpdater(
|
|
|
1118
1131
|
// navigation-transaction.ts CommitResult.
|
|
1119
1132
|
const update: NavigationUpdate = {
|
|
1120
1133
|
root: newTree,
|
|
1121
|
-
metadata:
|
|
1134
|
+
metadata: cachedHandleData
|
|
1135
|
+
? { ...payload.metadata, cachedHandleData }
|
|
1136
|
+
: payload.metadata!,
|
|
1122
1137
|
...(navScroll && { scroll: navScroll }),
|
|
1123
1138
|
};
|
|
1124
1139
|
if (process.env.NODE_ENV !== "production") {
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
subscribeToAdaptiveStrategyChange,
|
|
43
43
|
} from "../prefetch/default-strategy.js";
|
|
44
44
|
import { getAppVersion } from "../app-version.js";
|
|
45
|
+
import { prefixBasename } from "../../router/basename.js";
|
|
45
46
|
import type { PrefetchStrategy } from "../../router/prefetch-default.js";
|
|
46
47
|
|
|
47
48
|
// The PrefetchStrategy union is defined in router/prefetch-default.ts (both
|
|
@@ -244,15 +245,12 @@ export const Link: ForwardRefExoticComponent<
|
|
|
244
245
|
// store and no origin: links stay internal.
|
|
245
246
|
const isExternal = isExternalUrl(to, ctx?.origin);
|
|
246
247
|
|
|
247
|
-
// Auto-prefix with basename for app-local paths
|
|
248
|
-
//
|
|
249
|
-
const resolvedTo = useMemo(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return to;
|
|
254
|
-
return to === "/" ? bn : bn + to;
|
|
255
|
-
}, [to, isExternal, ctx?.basename]);
|
|
248
|
+
// Auto-prefix with basename for app-local paths (prefixBasename: not when
|
|
249
|
+
// external, already prefixed, or not root-relative).
|
|
250
|
+
const resolvedTo = useMemo(
|
|
251
|
+
() => (isExternal ? to : prefixBasename(to, ctx?.basename)),
|
|
252
|
+
[to, isExternal, ctx?.basename],
|
|
253
|
+
);
|
|
256
254
|
|
|
257
255
|
// No explicit `prefetch` prop: fall back to the router-wide default from
|
|
258
256
|
// the router store. Adaptive is resolved against the input capability when a
|
|
@@ -311,13 +311,17 @@ export interface NavigationProviderProps {
|
|
|
311
311
|
* The document this provider hydrates (initBrowserApp; renderRoute with
|
|
312
312
|
* `hydrate: true`), whose store EventController.lockHydration locked.
|
|
313
313
|
* `settled` resolves once the stream has ended and React has revealed every
|
|
314
|
-
* boundary.
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* the
|
|
314
|
+
* boundary. Then a transition changes HydrationBarrierContext alone, and
|
|
315
|
+
* React commits it once every boundary has hydrated, one still suspended on
|
|
316
|
+
* the client (a lazy chunk, a deferred-hydration gate) included. Until that
|
|
317
|
+
* commit location state is `undefined`, as SSR rendered it, through any
|
|
318
|
+
* commit no payload follows (a shallow navigation), and the store is
|
|
319
|
+
* locked; after it the entry's state lands in a transition of its own
|
|
320
|
+
* and the store is released (handle data lands in that release
|
|
321
|
+
* notification). A navigation committed earlier releases the store before
|
|
322
|
+
* its update, for the whole hold when the navigation is held: an old-page
|
|
323
|
+
* boundary hydrating meanwhile reads the destination's values and can
|
|
324
|
+
* mismatch.
|
|
321
325
|
* Absent for a provider mounted on the client.
|
|
322
326
|
*/
|
|
323
327
|
hydration?: { settled: Promise<void> };
|
|
@@ -432,18 +436,18 @@ export function NavigationProvider({
|
|
|
432
436
|
return startConnectionWarmup();
|
|
433
437
|
}, [warmupEnabled]);
|
|
434
438
|
|
|
435
|
-
// The document has been revealed
|
|
436
|
-
//
|
|
437
|
-
// dehydrated
|
|
439
|
+
// The document has been revealed. The barrier must be the only update in
|
|
440
|
+
// its transition lane: then React holds the commit until every boundary
|
|
441
|
+
// still dehydrated beneath it has hydrated. Any other update in the lane
|
|
442
|
+
// (location state in its own startTransition here included) can let React
|
|
443
|
+
// commit around a boundary still suspended on the client. Why:
|
|
444
|
+
// docs/internal/execution-model.md, "The reveal barrier is alone in its lane".
|
|
438
445
|
useEffect(() => {
|
|
439
446
|
if (!hydration) return;
|
|
440
447
|
let cancelled = false;
|
|
441
448
|
void hydration.settled.then(() => {
|
|
442
449
|
if (cancelled || !hydrationOpen.current) return;
|
|
443
|
-
startTransition(() =>
|
|
444
|
-
setLocationState(eventController.getLocationState());
|
|
445
|
-
setHydrationBarrier((n) => n + 1);
|
|
446
|
-
});
|
|
450
|
+
startTransition(() => setHydrationBarrier((n) => n + 1));
|
|
447
451
|
});
|
|
448
452
|
return () => {
|
|
449
453
|
cancelled = true;
|
|
@@ -451,20 +455,31 @@ export function NavigationProvider({
|
|
|
451
455
|
}, []);
|
|
452
456
|
|
|
453
457
|
// After the barrier's commit no boundary under this provider is dehydrated:
|
|
454
|
-
//
|
|
458
|
+
// the entry's location state lands in a transition of its own, and the
|
|
459
|
+
// store is released so its handle writes reach their readers. A navigation
|
|
460
|
+
// that committed first has done both (store onUpdate below).
|
|
455
461
|
useEffect(() => {
|
|
456
|
-
if (hydrationBarrier === 0) return;
|
|
462
|
+
if (hydrationBarrier === 0 || !hydrationOpen.current) return;
|
|
457
463
|
hydrationOpen.current = false;
|
|
464
|
+
startTransition(() => setLocationState(eventController.getLocationState()));
|
|
458
465
|
eventController.releaseHydrationLock();
|
|
459
466
|
}, [hydrationBarrier]);
|
|
460
467
|
|
|
461
468
|
// A commit no payload follows (a shallow navigation, a commit that keeps
|
|
462
469
|
// every segment, merged server-set state): the tree on screen is already
|
|
463
|
-
// the entry's, so its state is taken with the commit's notification.
|
|
470
|
+
// the entry's, so its state is taken with the commit's notification. Not
|
|
471
|
+
// while the hydration window is open: a boundary of the document may still
|
|
472
|
+
// be dehydrated, an update a reader outside it takes commits around it, and
|
|
473
|
+
// it then hydrates against the new state. The barrier's effect gives the
|
|
474
|
+
// controller's state, this commit's included. The window stays open: this
|
|
475
|
+
// commit mounts nothing that must read the live store.
|
|
464
476
|
useEffect(
|
|
465
477
|
() =>
|
|
466
478
|
eventController.subscribe(() => {
|
|
467
|
-
if (
|
|
479
|
+
if (
|
|
480
|
+
eventController.takeTreelessLocationState() &&
|
|
481
|
+
!hydrationOpen.current
|
|
482
|
+
) {
|
|
468
483
|
setLocationState(eventController.getLocationState());
|
|
469
484
|
}
|
|
470
485
|
}),
|
|
@@ -541,18 +556,23 @@ export function NavigationProvider({
|
|
|
541
556
|
eventController.setParams(update.metadata.params);
|
|
542
557
|
}
|
|
543
558
|
|
|
559
|
+
// A page rendered from a history-cache entry (back/forward, or a link
|
|
560
|
+
// to it) starts from the entry's pushes, and its response's handles
|
|
561
|
+
// merge onto them: the page on screen's pushes are not its segments'.
|
|
562
|
+
const { matched, cachedHandleData } = update.metadata;
|
|
563
|
+
if (matched && cachedHandleData) {
|
|
564
|
+
eventController.setHandleData(cachedHandleData, matched);
|
|
565
|
+
}
|
|
544
566
|
// Update handle data progressively as it streams in
|
|
545
567
|
if (update.metadata.handles) {
|
|
546
568
|
consumeHandles(update.metadata, eventController, store);
|
|
547
|
-
} else if (
|
|
548
|
-
//
|
|
549
|
-
// partial cleanup of segments no longer matched.
|
|
550
|
-
const cached = update.metadata.cachedHandleData;
|
|
569
|
+
} else if (matched && !cachedHandleData) {
|
|
570
|
+
// Partial cleanup of segments no longer matched.
|
|
551
571
|
eventController.setHandleData(
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
572
|
+
{},
|
|
573
|
+
matched,
|
|
574
|
+
true,
|
|
575
|
+
update.metadata.resolvedIds,
|
|
556
576
|
);
|
|
557
577
|
}
|
|
558
578
|
|
|
@@ -91,8 +91,9 @@ export const LocationStateContext: Context<LocationStateSnapshot> =
|
|
|
91
91
|
* Read by no component. NavigationProvider changes its value in a transition so
|
|
92
92
|
* that React hydrates every boundary still dehydrated beneath it before that
|
|
93
93
|
* commit: React hydrates a dehydrated boundary first when a provider above it
|
|
94
|
-
* changes, and nothing re-renders for a value nobody reads.
|
|
95
|
-
*
|
|
94
|
+
* changes, and nothing re-renders for a value nobody reads. It must stay
|
|
95
|
+
* unread and alone in its lane (docs/internal/execution-model.md, "The reveal
|
|
96
|
+
* barrier is alone in its lane"). See NavigationProviderProps.hydration.
|
|
96
97
|
*/
|
|
97
98
|
export const HydrationBarrierContext: Context<number> =
|
|
98
99
|
createContext<number>(0);
|
|
@@ -5,6 +5,7 @@ import { NavigationStoreContext } from "./context.js";
|
|
|
5
5
|
import { useMount } from "./use-mount.js";
|
|
6
6
|
import { prefetchDirect } from "../prefetch/loader.js";
|
|
7
7
|
import { getAppVersion } from "../app-version.js";
|
|
8
|
+
import { prefixBasename } from "../../router/basename.js";
|
|
8
9
|
import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -51,10 +52,7 @@ export function useRouter(): RouterInstance {
|
|
|
51
52
|
return useMemo<RouterInstance>(() => {
|
|
52
53
|
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
53
54
|
function withBasename(url: string): string {
|
|
54
|
-
|
|
55
|
-
if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
|
|
56
|
-
return url;
|
|
57
|
-
return url === "/" ? bn : bn + url;
|
|
55
|
+
return prefixBasename(url, ctx!.basename);
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
/**
|
|
@@ -906,6 +906,12 @@ export function createServerActionBridge(
|
|
|
906
906
|
|
|
907
907
|
handle.complete(returnData);
|
|
908
908
|
return returnData;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
// What the call rejects with (a revalidation render that threw, a
|
|
911
|
+
// failed decode) is the action's error, for useAction as for the
|
|
912
|
+
// caller. A no-op for an aborted or already-settled action.
|
|
913
|
+
handle.fail(error);
|
|
914
|
+
throw error;
|
|
909
915
|
} finally {
|
|
910
916
|
// The single deferred invalidation + fence release for this action. Runs
|
|
911
917
|
// for every terminal that settles (normal, navigated-away, error, abort,
|
package/src/browser/types.ts
CHANGED
|
@@ -85,7 +85,11 @@ export interface RscMetadata {
|
|
|
85
85
|
* positions and must complete at the handler barrier.
|
|
86
86
|
*/
|
|
87
87
|
handlesLate?: AsyncGenerator<HandleData, void, unknown>;
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Handle data of the history-cache entry the update renders from (a
|
|
90
|
+
* back/forward restore, or a link to a cached page). It replaces the handle
|
|
91
|
+
* state, and `handles`, when present, merge onto it.
|
|
92
|
+
*/
|
|
89
93
|
cachedHandleData?: HandleData;
|
|
90
94
|
/**
|
|
91
95
|
* RSC version string from the server.
|
|
@@ -75,9 +75,18 @@ export function isInSkippedScanDir(
|
|
|
75
75
|
return dirs.some(isUniversallySkippedScanDir);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Directory names the source-file scans skip: findTsFiles() and the build
|
|
80
|
+
* loader pre-scan (expose-internal-ids.ts). The isUniversallySkippedScanDir()
|
|
81
|
+
* set plus `build`, which the router scans still enter.
|
|
82
|
+
*/
|
|
83
|
+
export function isSkippedSourceScanDir(name: string): boolean {
|
|
84
|
+
return isUniversallySkippedScanDir(name) || name === "build";
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
/**
|
|
79
88
|
* Recursively find .ts/.tsx files under a directory, skipping the directories
|
|
80
|
-
*
|
|
89
|
+
* isSkippedSourceScanDir() names and .gen. files.
|
|
81
90
|
*/
|
|
82
91
|
export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
|
|
83
92
|
const results: string[] = [];
|
|
@@ -93,9 +102,7 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
|
|
|
93
102
|
for (const entry of entries) {
|
|
94
103
|
const fullPath = join(dir, entry.name);
|
|
95
104
|
if (entry.isDirectory()) {
|
|
96
|
-
if (
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
105
|
+
if (isSkippedSourceScanDir(entry.name)) continue;
|
|
99
106
|
results.push(...findTsFiles(fullPath, filter));
|
|
100
107
|
} else if (
|
|
101
108
|
(entry.name.endsWith(".ts") ||
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document cache storage policy: which responses the document cache stores.
|
|
3
|
+
* A leaf (types and the per-client signal only), so request-scoped code can
|
|
4
|
+
* ask whether a render will be stored (documentCacheStoresRender) without
|
|
5
|
+
* importing the cache middleware.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hasPerClientSignal } from "../browser/cookie-name.js";
|
|
9
|
+
import type { RequestContext } from "../server/request-context.js";
|
|
10
|
+
|
|
11
|
+
export interface CacheDirectives {
|
|
12
|
+
sMaxAge?: number;
|
|
13
|
+
staleWhileRevalidate?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse Cache-Control header for s-maxage and stale-while-revalidate directives
|
|
18
|
+
*/
|
|
19
|
+
function parseCacheControl(header: string | null): CacheDirectives | null {
|
|
20
|
+
if (!header) return null;
|
|
21
|
+
|
|
22
|
+
// RFC 7234: in a SHARED cache, `private` and `no-store` forbid storage and
|
|
23
|
+
// MUST win over `s-maxage` even though `private, s-maxage` is contradictory.
|
|
24
|
+
// The document cache is a shared app store, so refuse both regardless of any
|
|
25
|
+
// s-maxage / stale-while-revalidate also present. Match standalone directive
|
|
26
|
+
// tokens (start/end, whitespace, comma, semicolon, or `=` bounded), not a
|
|
27
|
+
// substring, so a value containing "private" cannot false-veto.
|
|
28
|
+
if (/(^|[\s,;])(private|no-store)(?=$|[\s,;=])/i.test(header)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// RFC 7234 §5.2.2.2: a shared cache MUST NOT serve a stored `no-cache`
|
|
33
|
+
// response without successful origin validation. This store's hit path has no
|
|
34
|
+
// validation step, so serving a stored no-cache response within s-maxage
|
|
35
|
+
// would hand the client content the origin marked must-revalidate. Refuse to
|
|
36
|
+
// store it. Only UNqualified `no-cache` (no `=`) vetoes — the field-name-
|
|
37
|
+
// scoped `no-cache="set-cookie"` form IS storable per the RFC, so the `=`
|
|
38
|
+
// boundary is excluded from the lookahead (unlike private/no-store above).
|
|
39
|
+
if (/(^|[\s,;])no-cache(?=$|[\s,;])/i.test(header)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const directives: CacheDirectives = {};
|
|
44
|
+
|
|
45
|
+
// Parse s-maxage
|
|
46
|
+
const sMaxAgeMatch = header.match(/s-maxage\s*=\s*(\d+)/i);
|
|
47
|
+
if (sMaxAgeMatch) {
|
|
48
|
+
directives.sMaxAge = parseInt(sMaxAgeMatch[1], 10);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parse stale-while-revalidate
|
|
52
|
+
const swrMatch = header.match(/stale-while-revalidate\s*=\s*(\d+)/i);
|
|
53
|
+
if (swrMatch) {
|
|
54
|
+
directives.staleWhileRevalidate = parseInt(swrMatch[1], 10);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Only return if we have at least s-maxage (required for document caching)
|
|
58
|
+
if (directives.sMaxAge !== undefined) {
|
|
59
|
+
return directives;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if response should be cached based on Cache-Control headers
|
|
67
|
+
*/
|
|
68
|
+
export function shouldCacheResponse(
|
|
69
|
+
response: Response,
|
|
70
|
+
requestCtx?: Pick<
|
|
71
|
+
RequestContext,
|
|
72
|
+
"_payloadVisitorTheme" | "_payloadDeferred" | "_payloadVisitorLocation"
|
|
73
|
+
>,
|
|
74
|
+
): CacheDirectives | null {
|
|
75
|
+
// Only cache successful responses
|
|
76
|
+
if (response.status !== 200) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Never cache a per-client signal into a SHARED response store. A Set-Cookie
|
|
81
|
+
// (e.g. a rango state rotation from invalidateClientCache(), or any cookie a
|
|
82
|
+
// loader set) would be replayed to every client on a hit — pinning them to
|
|
83
|
+
// one value and even rolling a rotated client back to a prior one. The
|
|
84
|
+
// x-rango-keep-cache directive header is the mirror image: a replayed "keep"
|
|
85
|
+
// would suppress invalidation for every replayed client. Refuse both.
|
|
86
|
+
if (hasPerClientSignal(response.headers)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The payload carries the visitor's theme (#978): the render ran before the
|
|
91
|
+
// response opted in (a Cache-Control written after next()), so
|
|
92
|
+
// payloadInitialTheme could not render the default.
|
|
93
|
+
if (requestCtx?._payloadVisitorTheme) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A rewritten request's render shows the visitor's URL (renderedLocation,
|
|
98
|
+
// full-payload.ts); this entry is keyed by the rewrite target.
|
|
99
|
+
if (requestCtx?._payloadVisitorLocation) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// prefetch: false. The payload carries deferred units: it answers one
|
|
104
|
+
// page's prefetch and nothing else. parseCacheControl refuses the
|
|
105
|
+
// Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
|
|
106
|
+
// middleware can replace that after next(), and a navigation answered
|
|
107
|
+
// with this body would show a fallback no fill ever replaces.
|
|
108
|
+
if (requestCtx?._payloadDeferred) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const cacheControl = response.headers.get("Cache-Control");
|
|
113
|
+
return parseCacheControl(cacheControl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether this cache will store the response the current render produces, as
|
|
118
|
+
* far as the render can tell: the middleware runs it (a MISS or a stale
|
|
119
|
+
* refresh) and the response stub already opts in. payloadInitialTheme
|
|
120
|
+
* (rsc/full-payload.ts) then renders the no-cookie default theme (#978). A
|
|
121
|
+
* Cache-Control written after next() is not on the stub yet; for that render
|
|
122
|
+
* shouldCacheResponse refuses a payload marked `_payloadVisitorTheme`.
|
|
123
|
+
*/
|
|
124
|
+
export function documentCacheStoresRender(
|
|
125
|
+
reqCtx: Pick<RequestContext, "_documentCacheRender" | "res">,
|
|
126
|
+
): boolean {
|
|
127
|
+
return (
|
|
128
|
+
reqCtx._documentCacheRender === true &&
|
|
129
|
+
shouldCacheResponse(reqCtx.res) !== null
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
import { requestHeaders } from "../server/request-headers.js";
|
|
17
17
|
import { FILL_PARAM } from "../prefetch-markers.js";
|
|
18
18
|
import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
documentCacheStoresRender,
|
|
21
|
+
shouldCacheResponse,
|
|
22
|
+
} from "./document-cache-policy.js";
|
|
20
23
|
import {
|
|
21
24
|
getRequestContext,
|
|
22
25
|
runWithRequestContext,
|
|
@@ -37,11 +40,14 @@ import {
|
|
|
37
40
|
predatesInvalidation,
|
|
38
41
|
} from "./tag-invalidation.js";
|
|
39
42
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
43
|
+
import { isRewrite } from "../route-definition/rewrite.js";
|
|
40
44
|
import {
|
|
41
45
|
SEGMENT_FRAGMENT_CAPABILITY_HEADER,
|
|
42
46
|
SEGMENT_FRAGMENT_RECOVERY_HEADER,
|
|
43
47
|
} from "../segment-fragments.js";
|
|
44
48
|
|
|
49
|
+
export { documentCacheStoresRender };
|
|
50
|
+
|
|
45
51
|
const CACHE_STATUS_HEADER = "x-document-cache-status";
|
|
46
52
|
|
|
47
53
|
function collectRequestTags(
|
|
@@ -62,124 +68,6 @@ function hashSegmentIds(segmentIds: string): string {
|
|
|
62
68
|
return Math.abs(hash).toString(36);
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
// ============================================================================
|
|
66
|
-
|
|
67
|
-
interface CacheDirectives {
|
|
68
|
-
sMaxAge?: number;
|
|
69
|
-
staleWhileRevalidate?: number;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Parse Cache-Control header for s-maxage and stale-while-revalidate directives
|
|
74
|
-
*/
|
|
75
|
-
function parseCacheControl(header: string | null): CacheDirectives | null {
|
|
76
|
-
if (!header) return null;
|
|
77
|
-
|
|
78
|
-
// RFC 7234: in a SHARED cache, `private` and `no-store` forbid storage and
|
|
79
|
-
// MUST win over `s-maxage` even though `private, s-maxage` is contradictory.
|
|
80
|
-
// The document cache is a shared app store, so refuse both regardless of any
|
|
81
|
-
// s-maxage / stale-while-revalidate also present. Match standalone directive
|
|
82
|
-
// tokens (start/end, whitespace, comma, semicolon, or `=` bounded), not a
|
|
83
|
-
// substring, so a value containing "private" cannot false-veto.
|
|
84
|
-
if (/(^|[\s,;])(private|no-store)(?=$|[\s,;=])/i.test(header)) {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// RFC 7234 §5.2.2.2: a shared cache MUST NOT serve a stored `no-cache`
|
|
89
|
-
// response without successful origin validation. This store's hit path has no
|
|
90
|
-
// validation step, so serving a stored no-cache response within s-maxage
|
|
91
|
-
// would hand the client content the origin marked must-revalidate. Refuse to
|
|
92
|
-
// store it. Only UNqualified `no-cache` (no `=`) vetoes — the field-name-
|
|
93
|
-
// scoped `no-cache="set-cookie"` form IS storable per the RFC, so the `=`
|
|
94
|
-
// boundary is excluded from the lookahead (unlike private/no-store above).
|
|
95
|
-
if (/(^|[\s,;])no-cache(?=$|[\s,;])/i.test(header)) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const directives: CacheDirectives = {};
|
|
100
|
-
|
|
101
|
-
// Parse s-maxage
|
|
102
|
-
const sMaxAgeMatch = header.match(/s-maxage\s*=\s*(\d+)/i);
|
|
103
|
-
if (sMaxAgeMatch) {
|
|
104
|
-
directives.sMaxAge = parseInt(sMaxAgeMatch[1], 10);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Parse stale-while-revalidate
|
|
108
|
-
const swrMatch = header.match(/stale-while-revalidate\s*=\s*(\d+)/i);
|
|
109
|
-
if (swrMatch) {
|
|
110
|
-
directives.staleWhileRevalidate = parseInt(swrMatch[1], 10);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Only return if we have at least s-maxage (required for document caching)
|
|
114
|
-
if (directives.sMaxAge !== undefined) {
|
|
115
|
-
return directives;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Check if response should be cached based on Cache-Control headers
|
|
123
|
-
*/
|
|
124
|
-
function shouldCacheResponse(
|
|
125
|
-
response: Response,
|
|
126
|
-
requestCtx?: Pick<
|
|
127
|
-
RequestContext,
|
|
128
|
-
"_payloadVisitorTheme" | "_payloadDeferred"
|
|
129
|
-
>,
|
|
130
|
-
): CacheDirectives | null {
|
|
131
|
-
// Only cache successful responses
|
|
132
|
-
if (response.status !== 200) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Never cache a per-client signal into a SHARED response store. A Set-Cookie
|
|
137
|
-
// (e.g. a rango state rotation from invalidateClientCache(), or any cookie a
|
|
138
|
-
// loader set) would be replayed to every client on a hit — pinning them to
|
|
139
|
-
// one value and even rolling a rotated client back to a prior one. The
|
|
140
|
-
// x-rango-keep-cache directive header is the mirror image: a replayed "keep"
|
|
141
|
-
// would suppress invalidation for every replayed client. Refuse both.
|
|
142
|
-
if (hasPerClientSignal(response.headers)) {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// The payload carries the visitor's theme (#978): the render ran before the
|
|
147
|
-
// response opted in (a Cache-Control written after next()), so
|
|
148
|
-
// payloadInitialTheme could not render the default.
|
|
149
|
-
if (requestCtx?._payloadVisitorTheme) {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// prefetch: false. The payload carries deferred units: it answers one
|
|
154
|
-
// page's prefetch and nothing else. parseCacheControl refuses the
|
|
155
|
-
// Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
|
|
156
|
-
// middleware can replace that after next(), and a navigation answered
|
|
157
|
-
// with this body would show a fallback no fill ever replaces.
|
|
158
|
-
if (requestCtx?._payloadDeferred) {
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const cacheControl = response.headers.get("Cache-Control");
|
|
163
|
-
return parseCacheControl(cacheControl);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Whether this cache will store the response the current render produces, as
|
|
168
|
-
* far as the render can tell: the middleware runs it (a MISS or a stale
|
|
169
|
-
* refresh) and the response stub already opts in. payloadInitialTheme
|
|
170
|
-
* (rsc/full-payload.ts) then renders the no-cookie default theme (#978). A
|
|
171
|
-
* Cache-Control written after next() is not on the stub yet; for that render
|
|
172
|
-
* shouldCacheResponse refuses a payload marked `_payloadVisitorTheme`.
|
|
173
|
-
*/
|
|
174
|
-
export function documentCacheStoresRender(
|
|
175
|
-
reqCtx: Pick<RequestContext, "_documentCacheRender" | "res">,
|
|
176
|
-
): boolean {
|
|
177
|
-
return (
|
|
178
|
-
reqCtx._documentCacheRender === true &&
|
|
179
|
-
shouldCacheResponse(reqCtx.res) !== null
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
71
|
/**
|
|
184
72
|
* Throw the first error the drained render reported through Flight or Fizz
|
|
185
73
|
* onError. A component that throws after the 200 committed does not fail the
|
|
@@ -513,6 +401,9 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
513
401
|
),
|
|
514
402
|
);
|
|
515
403
|
} catch (error) {
|
|
404
|
+
// The route now rewrites (route middleware): nothing to refresh
|
|
405
|
+
// this entry with.
|
|
406
|
+
if (isRewrite(error)) return;
|
|
516
407
|
// Pass requestCtx explicitly: this runs in a detached waitUntil task
|
|
517
408
|
// where the ALS context is gone, so onError only fires if we hand it
|
|
518
409
|
// the captured context (reportCacheError falls back to _getRequestContext
|
|
@@ -600,6 +491,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
600
491
|
noteWarmDocument(requestCtx, "not-cacheable");
|
|
601
492
|
return originalResponse;
|
|
602
493
|
} catch (error) {
|
|
494
|
+
// A rewrite() thrown downstream re-dispatches the request.
|
|
495
|
+
if (isRewrite(error)) throw error;
|
|
603
496
|
reportCacheError(error, "cache-read", "[DocumentCache] middleware");
|
|
604
497
|
if (handlerCalled) {
|
|
605
498
|
// Post-handler failure (e.g. body.tee()): do not call next() again
|
|
@@ -194,13 +194,13 @@ export function ClientUrlsRoot({
|
|
|
194
194
|
});
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// Keyed per layout() item, not per route (#1124).
|
|
197
198
|
for (let index = route.layouts.length - 1; index >= 0; index--) {
|
|
198
|
-
const layoutKey = `${route.id}-layout-${index}`;
|
|
199
199
|
content = createElement(OutletProvider, {
|
|
200
|
-
key:
|
|
200
|
+
key: route.layoutKeys[index],
|
|
201
201
|
content,
|
|
202
202
|
pending,
|
|
203
|
-
children: createElement(route.layouts[index]
|
|
203
|
+
children: createElement(route.layouts[index]),
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|