@rangojs/router 0.22.1 → 0.24.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 +10 -4
- package/dist/testing/vitest.js +31 -1
- package/dist/types/browser/defer.d.ts +48 -0
- package/dist/types/browser/document-revealed.d.ts +17 -0
- package/dist/types/browser/event-controller.d.ts +7 -1
- package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
- package/dist/types/browser/navigation-transaction.d.ts +21 -7
- package/dist/types/browser/partial-update.d.ts +39 -8
- package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
- package/dist/types/browser/react/context.d.ts +3 -2
- package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
- package/dist/types/browser/response-adapter.d.ts +7 -0
- package/dist/types/browser/types.d.ts +24 -10
- package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +1 -1
- package/dist/types/build/route-types/scan-filter.d.ts +22 -2
- package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
- package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
- 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/client-urls.d.ts +1 -1
- package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
- package/dist/types/client-urls/navigation.d.ts +0 -37
- package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
- package/dist/types/client-urls/server-projection.d.ts +29 -1
- package/dist/types/client-urls/types.d.ts +34 -69
- package/dist/types/client.d.ts +0 -1
- package/dist/types/context-var.d.ts +12 -0
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/handles/deferred-resolution.d.ts +2 -1
- package/dist/types/handles/pushed-by.d.ts +31 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.rsc.d.ts +2 -0
- package/dist/types/loader-store.d.ts +8 -0
- package/dist/types/prefetch-markers.d.ts +11 -0
- package/dist/types/response-utils.d.ts +7 -0
- package/dist/types/reverse.d.ts +8 -8
- package/dist/types/route-definition/helpers-types.d.ts +27 -0
- 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/is-action.d.ts +2 -1
- 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/loader-mask.d.ts +3 -2
- package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
- 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/context.d.ts +21 -1
- package/dist/types/server/handle-store.d.ts +9 -1
- package/dist/types/server/request-context.d.ts +52 -2
- package/dist/types/server/rewrite-trail.d.ts +49 -0
- package/dist/types/server/slot-accessors.d.ts +29 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +4 -4
- package/dist/types/testing/internal/action-id.d.ts +10 -1
- package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
- package/dist/types/testing/internal/test-action.d.ts +8 -0
- package/dist/types/testing/render-route.d.ts +8 -0
- package/dist/types/testing/run-loader.d.ts +2 -1
- package/dist/types/testing/run-middleware.d.ts +20 -0
- package/dist/types/testing/run-revalidate.d.ts +74 -0
- package/dist/types/testing/run-transition-when.d.ts +2 -3
- package/dist/types/testing/serve-shell-request.d.ts +40 -1
- package/dist/types/testing/vitest.d.ts +11 -0
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/boundaries.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +14 -0
- package/dist/types/types/loader-types.d.ts +30 -0
- package/dist/types/types/request-scope.d.ts +7 -0
- package/dist/types/types/segments.d.ts +21 -4
- package/dist/types/urls/path-helper-types.d.ts +25 -4
- package/dist/types/urls/type-extraction.d.ts +6 -2
- package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
- package/dist/types/vite/discovery/state.d.ts +14 -4
- package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
- package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
- package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
- package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
- package/dist/types/vite/router-discovery.d.ts +10 -0
- package/dist/vite/index.js +974 -379
- package/package.json +1 -1
- package/skills/caching/SKILL.md +54 -5
- package/skills/catalog.json +3 -3
- package/skills/client-urls/SKILL.md +197 -24
- package/skills/cloudflare/SKILL.md +14 -9
- package/skills/hooks/SKILL.md +16 -3
- package/skills/hooks/data.md +3 -1
- package/skills/links/SKILL.md +24 -0
- package/skills/loader/SKILL.md +199 -2
- package/skills/middleware/SKILL.md +183 -2
- package/skills/migrate-nextjs/SKILL.md +2 -2
- package/skills/migrate-react-router/SKILL.md +10 -7
- package/skills/migrate-react-router/data-and-actions.md +1 -1
- package/skills/ppr/SKILL.md +22 -0
- package/skills/prerender/SKILL.md +22 -8
- package/skills/rango/SKILL.md +13 -7
- package/skills/route/SKILL.md +139 -0
- package/skills/server-actions/SKILL.md +27 -0
- package/skills/testing/SKILL.md +3 -2
- package/skills/testing/client-components.md +2 -1
- package/skills/testing/middleware.md +31 -10
- package/skills/testing/predicates.md +67 -32
- package/skills/testing/response-routes.md +1 -0
- package/skills/testing/serve-shell-request.md +107 -19
- package/skills/testing/setup.md +2 -2
- package/skills/typesafety/route-types.md +2 -2
- package/skills/use-cache/SKILL.md +4 -2
- package/skills/view-transitions/SKILL.md +26 -0
- package/src/__augment-tests__/augmented.check.ts +20 -0
- package/src/browser/defer.ts +82 -0
- package/src/browser/document-revealed.ts +52 -0
- package/src/browser/event-controller.ts +46 -32
- package/src/browser/merge-segment-loaders.ts +31 -18
- package/src/browser/navigation-bridge.ts +82 -16
- package/src/browser/navigation-client.ts +48 -36
- package/src/browser/navigation-store.ts +13 -0
- package/src/browser/navigation-transaction.ts +25 -8
- package/src/browser/partial-update.ts +554 -146
- package/src/browser/prefetch/fetch.ts +2 -1
- package/src/browser/react/Link.tsx +7 -9
- package/src/browser/react/NavigationProvider.tsx +88 -49
- package/src/browser/react/context.ts +3 -2
- package/src/browser/react/filter-segment-order.ts +13 -2
- package/src/browser/react/use-router.ts +2 -4
- package/src/browser/response-adapter.ts +20 -0
- package/src/browser/rsc-router.tsx +17 -45
- package/src/browser/server-action-bridge.ts +25 -63
- package/src/browser/types.ts +24 -10
- package/src/browser/validate-redirect-origin.ts +16 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/router-processing.ts +3 -6
- package/src/build/route-types/scan-filter.ts +45 -11
- package/src/cache/cf/cf-cache-store.ts +59 -21
- package/src/cache/cf/cf-cache-types.ts +24 -2
- package/src/cache/document-cache-policy.ts +131 -0
- package/src/cache/document-cache.ts +21 -107
- package/src/client-urls/client-urls.ts +22 -6
- package/src/client-urls/hoisted-revalidate.ts +25 -0
- package/src/client-urls/navigation.ts +0 -96
- package/src/client-urls/revalidate-registry.ts +30 -0
- package/src/client-urls/server-projection.ts +151 -65
- package/src/client-urls/types.ts +39 -71
- package/src/client.tsx +9 -5
- package/src/context-var.ts +32 -2
- package/src/errors.ts +34 -0
- package/src/handles/deferred-resolution.ts +28 -5
- package/src/handles/pushed-by.ts +148 -0
- package/src/index.rsc.ts +8 -0
- package/src/index.ts +7 -0
- package/src/loader-store.ts +18 -7
- package/src/prefetch-markers.ts +13 -0
- package/src/response-utils.ts +19 -0
- package/src/reverse.ts +17 -14
- package/src/route-content-wrapper.tsx +18 -4
- package/src/route-definition/dsl-helpers.ts +27 -4
- package/src/route-definition/helpers-types.ts +27 -1
- 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 +35 -16
- package/src/router/internal-params.ts +16 -0
- package/src/router/is-action.ts +2 -1
- package/src/router/loader-resolution.ts +85 -4
- package/src/router/match-api.ts +36 -1
- package/src/router/match-middleware/background-revalidation.ts +16 -4
- package/src/router/match-middleware/cache-lookup.ts +35 -7
- package/src/router/match-middleware/cache-store.ts +15 -1
- package/src/router/match-middleware/intercept-resolution.ts +4 -2
- package/src/router/match-result.ts +14 -2
- package/src/router/middleware-types.ts +16 -0
- package/src/router/middleware.ts +127 -49
- package/src/router/navigation-snapshot.ts +2 -1
- package/src/router/revalidation.ts +1 -1
- package/src/router/route-trie-builder.ts +28 -1
- package/src/router/segment-resolution/fresh.ts +22 -18
- package/src/router/segment-resolution/loader-cache.ts +2 -1
- package/src/router/segment-resolution/loader-mask.ts +5 -3
- 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 +583 -0
- package/src/router/segment-resolution/revalidation.ts +215 -82
- 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 +54 -9
- package/src/rsc/shell-capture.ts +2 -1
- package/src/rsc/ssr-setup.ts +11 -5
- package/src/segment-loader-promise.ts +23 -20
- package/src/segment-system.tsx +60 -26
- package/src/server/context.ts +39 -5
- package/src/server/cookie-parse.ts +36 -12
- package/src/server/cookie-store.ts +7 -3
- package/src/server/handle-store.ts +152 -25
- package/src/server/request-context.ts +175 -64
- 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/server.ts +4 -0
- package/src/ssr/index.tsx +118 -16
- package/src/testing/action-form.ts +2 -11
- package/src/testing/dispatch.ts +138 -67
- package/src/testing/e2e/page-helpers.ts +3 -1
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +7 -3
- package/src/testing/internal/action-id.ts +16 -1
- package/src/testing/internal/client-urls-projection.ts +70 -0
- package/src/testing/internal/test-action.ts +25 -0
- package/src/testing/render-route.tsx +16 -3
- package/src/testing/run-loader.ts +4 -1
- package/src/testing/run-middleware.ts +56 -11
- package/src/testing/run-revalidate.ts +142 -0
- package/src/testing/run-transition-when.ts +7 -8
- package/src/testing/serve-shell-request.ts +131 -8
- package/src/testing/vitest.ts +67 -1
- package/src/thenable-status.ts +11 -0
- package/src/types/boundaries.ts +12 -0
- package/src/types/handler-context.ts +14 -0
- package/src/types/loader-types.ts +30 -0
- package/src/types/request-scope.ts +8 -0
- package/src/types/segments.ts +26 -4
- package/src/urls/path-helper-types.ts +27 -5
- package/src/urls/type-extraction.ts +30 -10
- package/src/use-loader.tsx +18 -13
- package/src/vite/discovery/client-urls-projection.ts +20 -21
- package/src/vite/discovery/discover-routers.ts +94 -26
- package/src/vite/discovery/state.ts +28 -4
- package/src/vite/discovery/virtual-module-codegen.ts +22 -17
- package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
- package/src/vite/plugins/expose-internal-ids.ts +2 -3
- package/src/vite/plugins/hoist/literal.ts +417 -0
- package/src/vite/plugins/hoist/scope.ts +370 -0
- package/src/vite/plugins/hoist/source.ts +84 -0
- package/src/vite/plugins/transition-when-hoist.ts +113 -779
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +116 -53
- package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
- package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
- package/dist/types/testing/run-client-revalidate.d.ts +0 -43
- package/src/client-urls/revalidate-chain.ts +0 -83
- package/src/client-urls/revalidation-protocol.ts +0 -56
- package/src/testing/run-client-revalidate.ts +0 -92
|
@@ -11,9 +11,15 @@ import { startTransition } from "react";
|
|
|
11
11
|
// addTransitionType is only available in React experimental
|
|
12
12
|
const addTransitionType: ((type: string) => void) | undefined =
|
|
13
13
|
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
14
|
+
|
|
14
15
|
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
15
16
|
import { reconcileSegments } from "./segment-reconciler.js";
|
|
16
17
|
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
18
|
+
import { Defer } from "./defer.js";
|
|
19
|
+
import {
|
|
20
|
+
emitNavigationError,
|
|
21
|
+
toNetworkError,
|
|
22
|
+
} from "./network-error-handler.js";
|
|
17
23
|
import {
|
|
18
24
|
hasActiveIntercept as hasActiveInterceptSlots,
|
|
19
25
|
isInterceptSegment,
|
|
@@ -25,17 +31,11 @@ import {
|
|
|
25
31
|
isBrowserDebugEnabled,
|
|
26
32
|
IS_BROWSER_DEBUG,
|
|
27
33
|
} from "./logging.js";
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
validateExternalRedirect,
|
|
31
|
-
} from "./validate-redirect-origin.js";
|
|
32
|
-
import type { NavigationUpdate } from "./types.js";
|
|
34
|
+
import { validatePayloadRedirect } from "./validate-redirect-origin.js";
|
|
35
|
+
import type { HandleData, NavigationUpdate, RscMetadata } from "./types.js";
|
|
33
36
|
import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
|
|
34
37
|
import type { LoaderStore } from "../loader-store.js";
|
|
35
|
-
import {
|
|
36
|
-
collectClientRevalidationDecisions,
|
|
37
|
-
setActiveInterceptTargets,
|
|
38
|
-
} from "../client-urls/navigation.js";
|
|
38
|
+
import { setActiveInterceptTargets } from "../client-urls/navigation.js";
|
|
39
39
|
import {
|
|
40
40
|
decideCommitGatedOff,
|
|
41
41
|
mergeSegmentParams,
|
|
@@ -43,15 +43,9 @@ import {
|
|
|
43
43
|
} from "./transition-when.js";
|
|
44
44
|
import { buildHistoryState } from "./history-state.js";
|
|
45
45
|
import { addLocationState } from "./react/location-state-shared.js";
|
|
46
|
-
import type
|
|
46
|
+
import { isPlaceholder, type TransitionWhenKind } from "../types/segments.js";
|
|
47
47
|
import { auditTreeCause, type TreeUpdateCause } from "../suspense-audit.js";
|
|
48
48
|
|
|
49
|
-
function toScrollPayload(
|
|
50
|
-
scroll: boolean | undefined,
|
|
51
|
-
): NonNullable<NavigationUpdate["scroll"]> {
|
|
52
|
-
return { enabled: scroll !== false ? scroll : false };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
/**
|
|
56
50
|
* Hold/animate decision for a commit: a non-intercept segment carries a
|
|
57
51
|
* transition, no intercept is presented, and this navigation's
|
|
@@ -115,6 +109,66 @@ export interface PartialUpdateConfig {
|
|
|
115
109
|
) => Promise<ReactNode> | ReactNode;
|
|
116
110
|
/** RSC version getter — returns the current version (may change after HMR) */
|
|
117
111
|
getVersion?: () => string | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* A fill (`prefetch: false`) lands after its navigation has committed: the
|
|
114
|
+
* transaction has recorded the entry and handed React the tree, and the
|
|
115
|
+
* fill hands React no update that would carry what its response holds to
|
|
116
|
+
* the stores. The bridge that owns navigation supplies the writes, the
|
|
117
|
+
* ones its own updates make. Without them a redirect is a document
|
|
118
|
+
* navigation and the rest is dropped.
|
|
119
|
+
*/
|
|
120
|
+
fill?: {
|
|
121
|
+
redirect(url: string, state?: Record<string, unknown>): void;
|
|
122
|
+
/** The location state the server set, to the entry and its readers. */
|
|
123
|
+
locationState(state: Record<string, unknown>): void;
|
|
124
|
+
/** What the response's handlers pushed, to the handle store. */
|
|
125
|
+
handles(metadata: RscMetadata): void;
|
|
126
|
+
/**
|
|
127
|
+
* Deliver the navigation state to its readers now. A page built with
|
|
128
|
+
* holes commits in the task of its click, before the notification of the
|
|
129
|
+
* click has gone out (event-controller.ts makeDebouncedNotifier), and
|
|
130
|
+
* React holds that commit where a hole is read with no boundary: without
|
|
131
|
+
* this useNavigation() reads idle, on the page being left, for the whole
|
|
132
|
+
* wait. Delivered first, `loading` is on screen when the commit is
|
|
133
|
+
* handed over, and stays while React holds it, as for a plain click.
|
|
134
|
+
*/
|
|
135
|
+
flush(): void;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FILL_REDIRECT_NOT_FOLLOWED = "[rango] fill: redirect not followed";
|
|
140
|
+
|
|
141
|
+
/** Rewrite `into` in place: its holders keep the array. */
|
|
142
|
+
function replace<T>(into: T[], items: readonly T[]): void {
|
|
143
|
+
into.splice(0, into.length, ...items);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The ids of `matched` that `segments` lacks. */
|
|
147
|
+
function missingIds(
|
|
148
|
+
matched: readonly string[],
|
|
149
|
+
segments: readonly ResolvedSegment[],
|
|
150
|
+
): string[] {
|
|
151
|
+
const ids = new Set(segments.map((s) => s.id));
|
|
152
|
+
return matched.filter((id) => !ids.has(id));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A tree built with holes: one defer per placeholder segment, standing for
|
|
157
|
+
* the value its work did not produce. The segment is always new to the page:
|
|
158
|
+
* a payload that defers is adopted only on the page it was prefetched from,
|
|
159
|
+
* with the segments it was prefetched with (rsc/rsc-rendering.ts
|
|
160
|
+
* sourceScoped, prefetch/cache.ts buildSourceKey), and the server defers
|
|
161
|
+
* nothing that request listed as held. The navigation that commits the tree
|
|
162
|
+
* fills it from its fill request (fillHoles) or, when it is left first,
|
|
163
|
+
* releases it (docs/design/prefetch-false.md, "The browser").
|
|
164
|
+
*/
|
|
165
|
+
interface Holes {
|
|
166
|
+
/** Placeholder segment (the payload's object) -> its defer. */
|
|
167
|
+
defers: Map<ResolvedSegment, Defer<unknown>>;
|
|
168
|
+
/** The `matched` the tree was built for. */
|
|
169
|
+
matched: string[];
|
|
170
|
+
/** The answer to the fill request this navigation sent for them. */
|
|
171
|
+
answer: ReturnType<NavigationClient["fetchPartial"]>;
|
|
118
172
|
}
|
|
119
173
|
|
|
120
174
|
/**
|
|
@@ -146,18 +200,22 @@ export type UpdateMode =
|
|
|
146
200
|
* when we send cached segment IDs to the server - if the server returns empty diff,
|
|
147
201
|
* we use the same segments we told the server we have. */
|
|
148
202
|
targetCacheSegments?: ResolvedSegment[];
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
|
|
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;
|
|
152
208
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
153
209
|
interceptSourceUrl?: string;
|
|
154
210
|
/**
|
|
155
|
-
* The
|
|
156
|
-
*
|
|
211
|
+
* The optimistic clientUrls() destination the bridge presented for
|
|
212
|
+
* this navigation. Transition-lane commits add
|
|
157
213
|
* OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
|
|
158
|
-
* boundaries do not animate the identical repaint
|
|
214
|
+
* boundaries do not animate the identical repaint, and `clear()` runs
|
|
215
|
+
* once the canonical tree is handed to React: `useOutlet().pending`
|
|
216
|
+
* ends with that commit, not with a `prefetch: false` fill after it.
|
|
159
217
|
*/
|
|
160
|
-
|
|
218
|
+
presentation?: { clear(): void };
|
|
161
219
|
/**
|
|
162
220
|
* A router.refresh() (or cross-tab refresh) of the current URL:
|
|
163
221
|
* transition({ when }) sees kind "revalidate", not "replace".
|
|
@@ -197,10 +255,287 @@ export function createPartialUpdater(
|
|
|
197
255
|
getVersion = () => undefined,
|
|
198
256
|
} = config;
|
|
199
257
|
|
|
258
|
+
/**
|
|
259
|
+
* The segments the page on screen holds. A placeholder (`prefetch: false`,
|
|
260
|
+
* waiting for its fill) is not a copy of its segment: it never stands in
|
|
261
|
+
* for one in a reconcile, and its id is never sent as held (tx.commit below
|
|
262
|
+
* leaves it out of the store's ids).
|
|
263
|
+
*/
|
|
200
264
|
function getCurrentCachedSegments(): ResolvedSegment[] {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
265
|
+
return (entryOnScreen() || []).filter((s) => !isPlaceholder(s));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One defer per placeholder: a loader's is its `loaderData`, any other
|
|
270
|
+
* segment's is its `component`. Every reader of a hole (Suspender,
|
|
271
|
+
* useLoader, React itself where a segment has no loading()) reads it as a
|
|
272
|
+
* stream that has not arrived yet, and all of them wait on its one promise
|
|
273
|
+
* (browser/defer.ts readDefer, asStream).
|
|
274
|
+
*/
|
|
275
|
+
function armHoles(
|
|
276
|
+
placeholders: ResolvedSegment[],
|
|
277
|
+
): Map<ResolvedSegment, Defer<unknown>> {
|
|
278
|
+
const defers = new Map<ResolvedSegment, Defer<unknown>>();
|
|
279
|
+
for (const segment of placeholders) {
|
|
280
|
+
const hole = new Defer<unknown>();
|
|
281
|
+
if (segment.type === "loader") segment.loaderData = hole;
|
|
282
|
+
else segment.component = hole as unknown as ReactNode;
|
|
283
|
+
defers.set(segment, hole);
|
|
284
|
+
}
|
|
285
|
+
return defers;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A defer that will not be resolved must not stay a pending stream. It is
|
|
290
|
+
* never rejected either: a rejection is unhandled in every aggregate built
|
|
291
|
+
* over it (segment-loader-promise.ts).
|
|
292
|
+
*/
|
|
293
|
+
function releaseHoles(holes: Holes): void {
|
|
294
|
+
for (const [placeholder, hole] of holes.defers) {
|
|
295
|
+
if (placeholder.type === "loader") {
|
|
296
|
+
store.loaders.releasePendingStream(placeholder.loaderId!, hole.promise);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The entry on screen, while it still holds these placeholders. By object,
|
|
303
|
+
* not by history key: a shallow navigation that copied the entry to a new
|
|
304
|
+
* key is still the page the answer is for, and an action refetch that
|
|
305
|
+
* already rendered the missing segments is not.
|
|
306
|
+
*/
|
|
307
|
+
function entryToFill(holes: Holes): ResolvedSegment[] | undefined {
|
|
308
|
+
const live = entryOnScreen();
|
|
309
|
+
return live?.some((s) => holes.defers.has(s)) ? live : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The page on screen holds every one of `segments` (by id). */
|
|
313
|
+
function holdsAll(segments: ResolvedSegment[]): boolean {
|
|
314
|
+
const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
|
|
315
|
+
return segments.every((s) => onScreenIds.has(s.id));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function entryOnScreen(): ResolvedSegment[] | undefined {
|
|
319
|
+
return store.getCachedSegments(store.getHistoryKey())?.segments;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Act on the redirect a navigation was answered with. True when `metadata`
|
|
324
|
+
* carried one: the navigation is done here. A target in the app is thrown
|
|
325
|
+
* for the bridge to follow. An explicit off-host one
|
|
326
|
+
* (redirect(url, { external: true })) is a hard navigation, still
|
|
327
|
+
* scheme-validated (http/https only): `external` waives the same-origin
|
|
328
|
+
* check the app opted out of, not scheme safety, so a forged payload
|
|
329
|
+
* carrying a javascript: or data: URL cannot script via location.assign. A
|
|
330
|
+
* target the client refuses is dropped.
|
|
331
|
+
*/
|
|
332
|
+
function redirected(metadata: RscMetadata | undefined): boolean {
|
|
333
|
+
const redirect = metadata?.redirect;
|
|
334
|
+
if (!redirect) return false;
|
|
335
|
+
const target = validatePayloadRedirect(redirect);
|
|
336
|
+
if (!target) {
|
|
337
|
+
debugLog("[Browser] Ignoring blocked redirect payload");
|
|
338
|
+
} else if (redirect.external) {
|
|
339
|
+
debugLog("[Browser] External redirect (hard navigation)");
|
|
340
|
+
window.location.assign(target);
|
|
341
|
+
} else {
|
|
342
|
+
throw new ServerRedirect(target, metadata.locationState);
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Follow a redirect a fill was answered with: a replace navigation through
|
|
349
|
+
* the bridge, a document navigation for an external target or with no
|
|
350
|
+
* bridge. False when the client refuses the target.
|
|
351
|
+
*/
|
|
352
|
+
function followFillRedirect(
|
|
353
|
+
redirect: { url: string; external?: boolean },
|
|
354
|
+
state: Record<string, unknown> | undefined,
|
|
355
|
+
): boolean {
|
|
356
|
+
const target = validatePayloadRedirect(redirect);
|
|
357
|
+
if (!target) return false;
|
|
358
|
+
if (redirect.external || !config.fill) window.location.assign(target);
|
|
359
|
+
else config.fill.redirect(target, state);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fill the holes of the tree this navigation committed with its fill's
|
|
365
|
+
* answer (`holes.answer`): a partial payload that carries the segment each placeholder
|
|
366
|
+
* stands for. Each defer resolves with that segment's value, the stored
|
|
367
|
+
* entry is rewritten with the segments themselves, and what the answer's
|
|
368
|
+
* handlers pushed and the location state they set go to the stores their
|
|
369
|
+
* readers subscribe to. React is handed nothing: the tree that reads the
|
|
370
|
+
* defers is the page, and what resolves reaches it as a stream that
|
|
371
|
+
* arrived.
|
|
372
|
+
*
|
|
373
|
+
* Called after tx.commit(), so the answer can never land before the tree it
|
|
374
|
+
* fills. The navigation's signal is the one thing that cancels it: a newer
|
|
375
|
+
* navigation or a back/forward aborts the request while it waits for its
|
|
376
|
+
* response (navigation-client.ts), and an answer that arrives after the
|
|
377
|
+
* abort is dropped. Either way the defers are released, not resolved.
|
|
378
|
+
*
|
|
379
|
+
* One answer does replace the page: one whose segments are not the ones the
|
|
380
|
+
* tree was built for. A handler that was awaited threw or called
|
|
381
|
+
* notFound(), and its entry's segments are one error segment instead
|
|
382
|
+
* (router/segment-resolution/helpers.ts catchSegmentError). That is the
|
|
383
|
+
* outcome of the navigation, and the segments are rendered as a
|
|
384
|
+
* navigation's are. `gatedOff` is the tree's transition({ when }) decision,
|
|
385
|
+
* for that render.
|
|
386
|
+
*
|
|
387
|
+
* Never rejects.
|
|
388
|
+
*/
|
|
389
|
+
async function fillHoles(
|
|
390
|
+
holes: Holes,
|
|
391
|
+
signal: AbortSignal | undefined,
|
|
392
|
+
url: string,
|
|
393
|
+
gatedOff: boolean,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
let filled = false;
|
|
396
|
+
try {
|
|
397
|
+
const result = await holes.answer;
|
|
398
|
+
// A page that no longer waits for the answer (an action refetch
|
|
399
|
+
// rendered what the placeholders stand for) takes nothing of it, its
|
|
400
|
+
// redirect included.
|
|
401
|
+
const live = entryToFill(holes);
|
|
402
|
+
if (signal?.aborted || !live) return;
|
|
403
|
+
|
|
404
|
+
const metadata = result.payload.metadata;
|
|
405
|
+
if (metadata?.redirect) {
|
|
406
|
+
if (!followFillRedirect(metadata.redirect, metadata.locationState)) {
|
|
407
|
+
throw new Error(FILL_REDIRECT_NOT_FOLLOWED);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!metadata?.isPartial) {
|
|
412
|
+
throw new Error("[rango] fill: not a partial payload");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const matched = metadata.matched || [];
|
|
416
|
+
const reconciled = reconcileSegments({
|
|
417
|
+
actor: "stale-revalidation",
|
|
418
|
+
matched,
|
|
419
|
+
diff: metadata.diff || [],
|
|
420
|
+
serverSegments: metadata.segments || [],
|
|
421
|
+
cachedSegments: live.filter((s) => !isPlaceholder(s)),
|
|
422
|
+
insertMissingDiff: true,
|
|
423
|
+
});
|
|
424
|
+
const missing = missingIds(matched, reconciled.segments);
|
|
425
|
+
if (missing.length > 0) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`[rango] fill: missing segments [${missing.join(", ")}]`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Nothing of the answer is handed over before this point: what an
|
|
432
|
+
// answer the client then refuses had set would stay under the error.
|
|
433
|
+
if (metadata.locationState) {
|
|
434
|
+
config.fill?.locationState(metadata.locationState);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const placeholders = [...holes.defers.keys()];
|
|
438
|
+
const answers = placeholders.map((placeholder) =>
|
|
439
|
+
reconciled.segments.find(
|
|
440
|
+
(s) => s.id === placeholder.id && s.type === placeholder.type,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
if (
|
|
444
|
+
answers.includes(undefined) ||
|
|
445
|
+
matched.join() !== holes.matched.join()
|
|
446
|
+
) {
|
|
447
|
+
// The tree's handle stream (NavigationProvider processHandles) reads
|
|
448
|
+
// this array on every yield and drops the buckets of segments
|
|
449
|
+
// outside it.
|
|
450
|
+
replace(holes.matched, matched);
|
|
451
|
+
// Each place a hole stood is handed its defer again, resolved with
|
|
452
|
+
// what the answer put there, and its reader applies the read rule
|
|
453
|
+
// (browser/defer.ts): a reader that suspended on it while it mounted
|
|
454
|
+
// calls use() of the same promise, which React has marked; one React
|
|
455
|
+
// never read (a hole below another one) reads the value, so this
|
|
456
|
+
// urgent update shows no fallback the plain click does not show
|
|
457
|
+
// (navigation-adoption-loading.test.tsx, "a failed route's error").
|
|
458
|
+
const settled = new Map<ResolvedSegment, ResolvedSegment>();
|
|
459
|
+
for (const [placeholder, hole] of holes.defers) {
|
|
460
|
+
if (placeholder.type === "loader") continue;
|
|
461
|
+
// By id alone: an error segment takes its route's place.
|
|
462
|
+
const next = reconciled.mainSegments.find(
|
|
463
|
+
(s) => s.id === placeholder.id,
|
|
464
|
+
);
|
|
465
|
+
if (next && !(next.component instanceof Promise)) {
|
|
466
|
+
hole.resolve(next.component);
|
|
467
|
+
settled.set(next, {
|
|
468
|
+
...next,
|
|
469
|
+
component: hole as unknown as ReactNode,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const main = reconciled.mainSegments.map((s) => settled.get(s) ?? s);
|
|
474
|
+
const root = await renderSegments(main, {
|
|
475
|
+
transitionGatedOff: gatedOff,
|
|
476
|
+
interceptSegments:
|
|
477
|
+
reconciled.interceptSegments.length > 0
|
|
478
|
+
? reconciled.interceptSegments
|
|
479
|
+
: undefined,
|
|
480
|
+
});
|
|
481
|
+
if (signal?.aborted || entryOnScreen() !== live) return;
|
|
482
|
+
releaseHoles(holes);
|
|
483
|
+
// The navigation's outcome (see fillHoles), so its cause.
|
|
484
|
+
if (process.env.NODE_ENV !== "production") {
|
|
485
|
+
auditTreeCause("navigation");
|
|
486
|
+
}
|
|
487
|
+
// No `scroll`: the transaction decided it with the click's update.
|
|
488
|
+
// A decision here, even "do not scroll", would replace that pending
|
|
489
|
+
// one (NavigationProvider).
|
|
490
|
+
onUpdate({ root, metadata });
|
|
491
|
+
} else {
|
|
492
|
+
config.fill?.handles(metadata);
|
|
493
|
+
// All in one task, each with its own stream or node: a hole below
|
|
494
|
+
// another one is read in the render the outer one's resolution
|
|
495
|
+
// starts. Resolved by then with a node, it costs that render nothing
|
|
496
|
+
// where a router reader reads it (browser/defer.ts readDefer); with a
|
|
497
|
+
// stream, or where React reads the node itself, one microtask. Not a
|
|
498
|
+
// fallback.
|
|
499
|
+
placeholders.forEach((placeholder, index) => {
|
|
500
|
+
const next = answers[index]!;
|
|
501
|
+
holes.defers
|
|
502
|
+
.get(placeholder)!
|
|
503
|
+
.resolve(
|
|
504
|
+
placeholder.type === "loader" ? next.loaderData : next.component,
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The entry keeps the answer's own streams and nodes, not the defers:
|
|
510
|
+
// a defer belongs to the tree on screen, and the next render from this
|
|
511
|
+
// page builds on the entry. The answer belongs to the visit that built
|
|
512
|
+
// the tree: the entry is rewritten in place, not re-cached.
|
|
513
|
+
// cacheSegmentsForHistory would advance the store's nav instance and
|
|
514
|
+
// disown the tree's handle stream, and would reset the entry's stale
|
|
515
|
+
// flag.
|
|
516
|
+
replace(live, reconciled.segments);
|
|
517
|
+
store.setSegmentIds(matched);
|
|
518
|
+
filled = true;
|
|
519
|
+
} catch (thrown) {
|
|
520
|
+
// Only on a page that still waits for this answer.
|
|
521
|
+
if (signal?.aborted || !entryToFill(holes)) return;
|
|
522
|
+
let error = thrown;
|
|
523
|
+
if (error instanceof ServerRedirect) {
|
|
524
|
+
if (followFillRedirect({ url: error.url }, error.state)) return;
|
|
525
|
+
error = new Error(FILL_REDIRECT_NOT_FOLLOWED);
|
|
526
|
+
}
|
|
527
|
+
console.error("[rango] fill failed:", error);
|
|
528
|
+
// Shown the way a navigation the client cannot process is
|
|
529
|
+
// (navigation-bridge.ts): the error replaces the page, so an error
|
|
530
|
+
// boundary takes over and no fallback is left waiting.
|
|
531
|
+
emitNavigationError(
|
|
532
|
+
onUpdate,
|
|
533
|
+
toNetworkError(error, { url, operation: "navigation" }) ?? error,
|
|
534
|
+
url,
|
|
535
|
+
);
|
|
536
|
+
} finally {
|
|
537
|
+
if (!filled) releaseHoles(holes);
|
|
538
|
+
}
|
|
204
539
|
}
|
|
205
540
|
|
|
206
541
|
async function fetchPartialUpdate(
|
|
@@ -310,37 +645,22 @@ export function createPartialUpdater(
|
|
|
310
645
|
: undefined;
|
|
311
646
|
const cachedSegs = targetCache ?? getCurrentCachedSegments();
|
|
312
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;
|
|
313
658
|
if (IS_BROWSER_DEBUG) {
|
|
314
659
|
debugLog(
|
|
315
660
|
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
316
661
|
);
|
|
317
662
|
}
|
|
318
663
|
|
|
319
|
-
// Client-run per-loader revalidation: execute the held clientUrls route's
|
|
320
|
-
// revalidate() predicates now and ship their decisions with the request.
|
|
321
|
-
// Fails soft to null (locked server defaults) when no group is active or
|
|
322
|
-
// URLs do not parse.
|
|
323
|
-
let clientRevalidation: string | null = null;
|
|
324
|
-
try {
|
|
325
|
-
clientRevalidation = collectClientRevalidationDecisions({
|
|
326
|
-
currentUrl: new URL(previousUrl, window.location.origin),
|
|
327
|
-
nextUrl: new URL(url, window.location.origin),
|
|
328
|
-
// This partial fetch is a GET the server evaluates WITHOUT
|
|
329
|
-
// actionContext (navigation defaults) even when it is an
|
|
330
|
-
// action-triggered refetch — so the decision baseline is never the
|
|
331
|
-
// action default here. Predicates still see isAction()/actionId
|
|
332
|
-
// truthfully for matching.
|
|
333
|
-
actionRequest: false,
|
|
334
|
-
isAction: mode.type === "action",
|
|
335
|
-
...(mode.type === "action" && mode.actionId !== undefined
|
|
336
|
-
? { actionId: mode.actionId }
|
|
337
|
-
: {}),
|
|
338
|
-
stale: mode.type === "stale-revalidation",
|
|
339
|
-
});
|
|
340
|
-
} catch {
|
|
341
|
-
clientRevalidation = null;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
664
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
345
665
|
fetchResult = await client.fetchPartial({
|
|
346
666
|
targetUrl: url,
|
|
@@ -348,7 +668,6 @@ export function createPartialUpdater(
|
|
|
348
668
|
previousUrl,
|
|
349
669
|
staleRevalidation:
|
|
350
670
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
351
|
-
clientRevalidation,
|
|
352
671
|
version: getVersion(),
|
|
353
672
|
routerId: store.getRouterId?.(),
|
|
354
673
|
});
|
|
@@ -363,7 +682,26 @@ export function createPartialUpdater(
|
|
|
363
682
|
// Side effect only: end the streaming token once the stream settles.
|
|
364
683
|
// The wrapped promise was never read as a value; only the .end() matters.
|
|
365
684
|
// The .catch keeps an unhandled rejection from leaking if the stream errors.
|
|
366
|
-
|
|
685
|
+
// A payload with deferred segments is still streaming until its fill has
|
|
686
|
+
// (`filled` is set below, before this function first awaits). A
|
|
687
|
+
// navigation that is left ignores its response, which is never cancelled
|
|
688
|
+
// (navigation-client.ts): it streams to its end, and stops counting as
|
|
689
|
+
// streaming at the abort.
|
|
690
|
+
let filled: Promise<unknown> | undefined;
|
|
691
|
+
const endStream = (): void => {
|
|
692
|
+
signal?.removeEventListener("abort", endStream);
|
|
693
|
+
streamingToken.end();
|
|
694
|
+
};
|
|
695
|
+
signal?.addEventListener("abort", endStream);
|
|
696
|
+
rawStreamComplete
|
|
697
|
+
.then(() => (filled ? filled.then(endStream) : endStream()))
|
|
698
|
+
.catch(() => {});
|
|
699
|
+
|
|
700
|
+
if (signal?.aborted) {
|
|
701
|
+
endStream();
|
|
702
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
367
705
|
|
|
368
706
|
const currentRouterId = store.getRouterId?.();
|
|
369
707
|
if (
|
|
@@ -379,49 +717,12 @@ export function createPartialUpdater(
|
|
|
379
717
|
return;
|
|
380
718
|
}
|
|
381
719
|
|
|
382
|
-
if (payload.metadata
|
|
383
|
-
if (signal?.aborted) {
|
|
384
|
-
debugLog("[Browser] Ignoring stale redirect (aborted)");
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
// Explicit off-host redirect (redirect(url, { external: true })):
|
|
388
|
-
// hard-navigate, but still scheme-validate (http/https only). external
|
|
389
|
-
// waives the same-origin check the app opted out of, NOT scheme safety, so
|
|
390
|
-
// a forged payload carrying a javascript:/data: URL cannot script via
|
|
391
|
-
// location.assign.
|
|
392
|
-
if (payload.metadata.redirect.external) {
|
|
393
|
-
const externalUrl = validateExternalRedirect(
|
|
394
|
-
payload.metadata.redirect.url,
|
|
395
|
-
window.location.origin,
|
|
396
|
-
);
|
|
397
|
-
if (!externalUrl) {
|
|
398
|
-
debugLog("[Browser] Ignoring blocked external redirect payload");
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
debugLog("[Browser] External redirect (hard navigation)");
|
|
402
|
-
window.location.assign(externalUrl);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
const redirectUrl = validateRedirectOrigin(
|
|
406
|
-
payload.metadata.redirect.url,
|
|
407
|
-
window.location.origin,
|
|
408
|
-
);
|
|
409
|
-
if (!redirectUrl) {
|
|
410
|
-
debugLog("[Browser] Ignoring blocked redirect payload");
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
const serverState = payload.metadata.locationState;
|
|
414
|
-
throw new ServerRedirect(redirectUrl, serverState);
|
|
415
|
-
}
|
|
720
|
+
if (redirected(payload.metadata)) return;
|
|
416
721
|
|
|
417
722
|
if (payload.metadata?.isPartial) {
|
|
418
723
|
const { segments: newSegments, matched, diff } = payload.metadata;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (signal?.aborted) {
|
|
422
|
-
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
724
|
+
let serverSegments = newSegments || [];
|
|
725
|
+
let sent = diff || [];
|
|
425
726
|
|
|
426
727
|
if (IS_BROWSER_DEBUG) {
|
|
427
728
|
debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
|
|
@@ -448,17 +749,18 @@ export function createPartialUpdater(
|
|
|
448
749
|
forceAwait: true,
|
|
449
750
|
transitionGatedOff: cachedGatedOff,
|
|
450
751
|
});
|
|
752
|
+
if (signal?.aborted) return;
|
|
451
753
|
|
|
754
|
+
const cachedHandleData = readCachedHandleData?.();
|
|
452
755
|
const { scroll: commitScroll } = tx.commit(
|
|
453
756
|
matchedIds,
|
|
454
757
|
existingSegments,
|
|
455
758
|
{ routeName: payload.metadata.routeName },
|
|
456
759
|
);
|
|
457
|
-
|
|
458
|
-
if (mode.targetCacheHandleData) {
|
|
760
|
+
if (cachedHandleData) {
|
|
459
761
|
store.updateCacheHandleData(
|
|
460
762
|
store.getHistoryKey(),
|
|
461
|
-
|
|
763
|
+
cachedHandleData,
|
|
462
764
|
);
|
|
463
765
|
}
|
|
464
766
|
|
|
@@ -466,11 +768,8 @@ export function createPartialUpdater(
|
|
|
466
768
|
payload.metadata!;
|
|
467
769
|
const cachedUpdate = {
|
|
468
770
|
root: newTree,
|
|
469
|
-
metadata: {
|
|
470
|
-
|
|
471
|
-
cachedHandleData: mode.targetCacheHandleData,
|
|
472
|
-
},
|
|
473
|
-
scroll: toScrollPayload(commitScroll),
|
|
771
|
+
metadata: { ...metadataWithoutHandles, cachedHandleData },
|
|
772
|
+
...(commitScroll && { scroll: commitScroll }),
|
|
474
773
|
};
|
|
475
774
|
|
|
476
775
|
if (process.env.NODE_ENV !== "production") {
|
|
@@ -500,6 +799,7 @@ export function createPartialUpdater(
|
|
|
500
799
|
const newTree = await renderSegments(existingSegments, {
|
|
501
800
|
forceAwait: true,
|
|
502
801
|
});
|
|
802
|
+
if (signal?.aborted) return;
|
|
503
803
|
|
|
504
804
|
const { scroll: leaveScroll } = tx.commit(
|
|
505
805
|
matchedIds,
|
|
@@ -513,7 +813,7 @@ export function createPartialUpdater(
|
|
|
513
813
|
onUpdate({
|
|
514
814
|
root: newTree,
|
|
515
815
|
metadata: payload.metadata,
|
|
516
|
-
scroll:
|
|
816
|
+
...(leaveScroll && { scroll: leaveScroll }),
|
|
517
817
|
});
|
|
518
818
|
|
|
519
819
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -531,33 +831,49 @@ export function createPartialUpdater(
|
|
|
531
831
|
return;
|
|
532
832
|
}
|
|
533
833
|
|
|
534
|
-
|
|
834
|
+
let matchedIds = matched || [];
|
|
535
835
|
const actor: ReconcileActor =
|
|
536
836
|
mode.type === "stale-revalidation" || mode.type === "action"
|
|
537
837
|
? "stale-revalidation"
|
|
538
838
|
: "navigation";
|
|
539
839
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
840
|
+
// prefetch: false. A payload that carries deferred segments (an adopted
|
|
841
|
+
// prefetch, whatever answered it) is the page with holes in it: one
|
|
842
|
+
// placeholder per segment whose work did not run, each with what its
|
|
843
|
+
// wrapper chain is built from. The tree is built once, with a deferred
|
|
844
|
+
// in place of each missing value, and this navigation fills them from
|
|
845
|
+
// one fill request once it has committed the tree.
|
|
846
|
+
let defers: Holes["defers"] | undefined;
|
|
847
|
+
let heldIds = matchedIds;
|
|
848
|
+
const placeholders = serverSegments.filter(isPlaceholder);
|
|
849
|
+
if (placeholders.length > 0) {
|
|
850
|
+
defers = armHoles(placeholders);
|
|
851
|
+
// A placeholder is not held: every later request from this page
|
|
852
|
+
// leaves its id out, so the server renders it.
|
|
853
|
+
const deferredIds = new Set(placeholders.map((s) => s.id));
|
|
854
|
+
heldIds = matchedIds.filter((id) => !deferredIds.has(id));
|
|
855
|
+
}
|
|
548
856
|
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
857
|
+
const reconcile = () =>
|
|
858
|
+
reconcileSegments({
|
|
859
|
+
actor,
|
|
860
|
+
matched: matchedIds,
|
|
861
|
+
diff: sent,
|
|
862
|
+
serverSegments,
|
|
863
|
+
cachedSegments: cachedSegs,
|
|
864
|
+
insertMissingDiff: true,
|
|
865
|
+
});
|
|
866
|
+
let reconciled = reconcile();
|
|
553
867
|
|
|
554
|
-
|
|
555
|
-
|
|
868
|
+
const missing = missingIds(matchedIds, reconciled.segments);
|
|
869
|
+
|
|
870
|
+
if (missing.length > 0) {
|
|
871
|
+
const missingCount = missing.length;
|
|
556
872
|
|
|
557
873
|
if (isRetry) {
|
|
558
|
-
console.warn("Missing ids", { missingIds });
|
|
874
|
+
console.warn("Missing ids", { missingIds: missing });
|
|
559
875
|
throw new Error(
|
|
560
|
-
`[Browser] Failed to fetch segments after retry. Missing: [${
|
|
876
|
+
`[Browser] Failed to fetch segments after retry. Missing: [${missing.join(", ")}]`,
|
|
561
877
|
);
|
|
562
878
|
}
|
|
563
879
|
if (signal?.aborted) {
|
|
@@ -593,6 +909,81 @@ export function createPartialUpdater(
|
|
|
593
909
|
// reach renderSegments. The response carries no decision, so a reused
|
|
594
910
|
// prefetch is decided against the real source.
|
|
595
911
|
const gatedOff = decideGatedOff(reconciled.segments, payload.metadata);
|
|
912
|
+
|
|
913
|
+
// The fill: one request for what the prefetch deferred, listing what
|
|
914
|
+
// the client holds once this payload commits. It leaves now, before
|
|
915
|
+
// the tree is rendered, with this navigation's signal.
|
|
916
|
+
let holes: Holes | undefined;
|
|
917
|
+
if (defers) {
|
|
918
|
+
holes = {
|
|
919
|
+
defers,
|
|
920
|
+
matched: matchedIds,
|
|
921
|
+
answer: client.fetchPartial({
|
|
922
|
+
targetUrl: url,
|
|
923
|
+
segmentIds: heldIds,
|
|
924
|
+
// The page the fill completes is the page it is on.
|
|
925
|
+
previousUrl: url,
|
|
926
|
+
fill: true,
|
|
927
|
+
signal,
|
|
928
|
+
version: getVersion(),
|
|
929
|
+
routerId: store.getRouterId?.(),
|
|
930
|
+
}),
|
|
931
|
+
};
|
|
932
|
+
filled = holes.answer
|
|
933
|
+
.then((answer) => answer.streamComplete)
|
|
934
|
+
.catch(() => {});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// What a fill brought for a page that waited for it (below).
|
|
938
|
+
let waited: RscMetadata | undefined;
|
|
939
|
+
if (
|
|
940
|
+
holes &&
|
|
941
|
+
placeholders.some((s) => s.type !== "loader") &&
|
|
942
|
+
shouldStartViewTransition(reconciled.segments, gatedOff)
|
|
943
|
+
) {
|
|
944
|
+
// A deferred handler on a page that commits in a transition. React
|
|
945
|
+
// renders such a page's content in the commit that shows its
|
|
946
|
+
// fallback, in a render that can wait: a plain click commits it with
|
|
947
|
+
// its response, URL, hooks and page together. Committed with the
|
|
948
|
+
// click and filled afterwards, the URL moved a round trip before the
|
|
949
|
+
// page, and no later commit could bring the content in without a
|
|
950
|
+
// view transition the plain click does not start (measured: three
|
|
951
|
+
// for two, the content up to 280 ms late). So this click is a
|
|
952
|
+
// navigation that waits for its fill: nothing commits until the
|
|
953
|
+
// fill's first chunk, and then the page commits once, from the
|
|
954
|
+
// prefetched segments and the fill's, with no hole in it.
|
|
955
|
+
const { answer } = holes;
|
|
956
|
+
holes = undefined;
|
|
957
|
+
waited = (await answer).payload.metadata;
|
|
958
|
+
if (signal?.aborted || redirected(waited)) return;
|
|
959
|
+
if (waited?.isPartial) {
|
|
960
|
+
matchedIds = heldIds = waited.matched || [];
|
|
961
|
+
serverSegments = [
|
|
962
|
+
...serverSegments.filter((s) => !isPlaceholder(s)),
|
|
963
|
+
...(waited.segments || []),
|
|
964
|
+
];
|
|
965
|
+
sent = [...sent, ...(waited.diff || [])];
|
|
966
|
+
reconciled = reconcile();
|
|
967
|
+
if (waited.locationState) {
|
|
968
|
+
payload.metadata.locationState = {
|
|
969
|
+
...payload.metadata.locationState,
|
|
970
|
+
...waited.locationState,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// The two answers do not make the page (the routes changed between
|
|
975
|
+
// the prefetch and the click): start over with nothing held, as a
|
|
976
|
+
// navigation whose own response misses segments does above. That
|
|
977
|
+
// request is never answered from a prefetch, so it defers nothing.
|
|
978
|
+
if (
|
|
979
|
+
!waited?.isPartial ||
|
|
980
|
+
missingIds(matchedIds, reconciled.segments).length > 0
|
|
981
|
+
) {
|
|
982
|
+
return fetchPartialUpdate(url, [], true, signal, tx, mode);
|
|
983
|
+
}
|
|
984
|
+
} else if (holes) {
|
|
985
|
+
config.fill?.flush();
|
|
986
|
+
}
|
|
596
987
|
const renderOptions = {
|
|
597
988
|
transitionGatedOff: gatedOff,
|
|
598
989
|
isAction: mode.type === "action",
|
|
@@ -600,8 +991,10 @@ export function createPartialUpdater(
|
|
|
600
991
|
// land without a loading()/fallback frame. A fully-prefetched nav has
|
|
601
992
|
// its router data already resolved (the prefetch stream drained), so
|
|
602
993
|
// awaiting it here is free; the commit below then runs in a transition
|
|
603
|
-
// (fullyPrefetched branch) so nothing router-owned can flash.
|
|
604
|
-
|
|
994
|
+
// (fullyPrefetched branch) so nothing router-owned can flash. A page
|
|
995
|
+
// that waited for its fill is still streaming it.
|
|
996
|
+
forceAwait:
|
|
997
|
+
mode.type === "stale-revalidation" || (fullyPrefetched && !waited),
|
|
605
998
|
interceptSegments:
|
|
606
999
|
reconciled.interceptSegments.length > 0
|
|
607
1000
|
? reconciled.interceptSegments
|
|
@@ -657,10 +1050,8 @@ export function createPartialUpdater(
|
|
|
657
1050
|
// cache, which says nothing about what is visible now. Restricted to
|
|
658
1051
|
// plain navigations — leave-intercept must close its modal urgently,
|
|
659
1052
|
// and action/stale modes have their own transition branch.
|
|
660
|
-
const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
|
|
661
1053
|
const isSameStructureNav =
|
|
662
|
-
mode.type === "navigate" &&
|
|
663
|
-
reconciled.segments.every((s) => onScreenIds.has(s.id));
|
|
1054
|
+
mode.type === "navigate" && holdsAll(reconciled.segments);
|
|
664
1055
|
|
|
665
1056
|
const effectiveInterceptSource =
|
|
666
1057
|
interceptSourceUrl || segmentState.currentUrl;
|
|
@@ -672,7 +1063,6 @@ export function createPartialUpdater(
|
|
|
672
1063
|
}
|
|
673
1064
|
}
|
|
674
1065
|
|
|
675
|
-
const allSegmentIds = matchedIds;
|
|
676
1066
|
const serverLocationState = payload.metadata?.locationState;
|
|
677
1067
|
const overrides: CommitOverrides = {
|
|
678
1068
|
routeName: payload.metadata?.routeName,
|
|
@@ -683,11 +1073,21 @@ export function createPartialUpdater(
|
|
|
683
1073
|
}),
|
|
684
1074
|
...(serverLocationState && { serverState: serverLocationState }),
|
|
685
1075
|
};
|
|
1076
|
+
const cachedHandleData = readCachedHandleData?.();
|
|
686
1077
|
const { scroll: navScroll } = tx.commit(
|
|
687
|
-
|
|
1078
|
+
heldIds,
|
|
688
1079
|
reconciled.segments,
|
|
689
1080
|
overrides,
|
|
690
1081
|
);
|
|
1082
|
+
if (cachedHandleData) {
|
|
1083
|
+
store.updateCacheHandleData(store.getHistoryKey(), cachedHandleData);
|
|
1084
|
+
}
|
|
1085
|
+
const hasTransition = shouldStartViewTransition(
|
|
1086
|
+
reconciled.segments,
|
|
1087
|
+
gatedOff,
|
|
1088
|
+
);
|
|
1089
|
+
// After tx.commit(): the handle stream belongs to the entry it made.
|
|
1090
|
+
if (waited) config.fill?.handles(waited);
|
|
691
1091
|
|
|
692
1092
|
if (mode.type === "stale-revalidation") {
|
|
693
1093
|
const historyKeyNow = store.getHistoryKey();
|
|
@@ -701,12 +1101,9 @@ export function createPartialUpdater(
|
|
|
701
1101
|
|
|
702
1102
|
debugLog("[partial-update] updating document");
|
|
703
1103
|
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
);
|
|
708
|
-
const optimisticPresented =
|
|
709
|
-
mode.type === "navigate" && mode.optimisticPresented === true;
|
|
1104
|
+
const presentation =
|
|
1105
|
+
mode.type === "navigate" ? mode.presentation : undefined;
|
|
1106
|
+
const optimisticPresented = presentation !== undefined;
|
|
710
1107
|
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
|
|
711
1108
|
// segment still carries a transition after the server-side when-gate, and
|
|
712
1109
|
// whether the commit will be held in a startTransition. If `withTransition`
|
|
@@ -730,12 +1127,14 @@ export function createPartialUpdater(
|
|
|
730
1127
|
// metadata); popstate navigations get no optimistic presentation anyway.
|
|
731
1128
|
setActiveInterceptTargets(payload.metadata?.interceptTargets);
|
|
732
1129
|
|
|
733
|
-
|
|
734
|
-
|
|
1130
|
+
// No scroll for a commit that is not a navigation (an action refetch):
|
|
1131
|
+
// navigation-transaction.ts CommitResult.
|
|
735
1132
|
const update: NavigationUpdate = {
|
|
736
1133
|
root: newTree,
|
|
737
|
-
metadata:
|
|
738
|
-
|
|
1134
|
+
metadata: cachedHandleData
|
|
1135
|
+
? { ...payload.metadata, cachedHandleData }
|
|
1136
|
+
: payload.metadata!,
|
|
1137
|
+
...(navScroll && { scroll: navScroll }),
|
|
739
1138
|
};
|
|
740
1139
|
if (process.env.NODE_ENV !== "production") {
|
|
741
1140
|
auditTreeCause(treeCause(mode, tx.traversal));
|
|
@@ -763,11 +1162,19 @@ export function createPartialUpdater(
|
|
|
763
1162
|
);
|
|
764
1163
|
} else if (
|
|
765
1164
|
!gatedOff &&
|
|
766
|
-
(fullyPrefetched
|
|
1165
|
+
((fullyPrefetched && !holes) ||
|
|
1166
|
+
isSameStructureNav ||
|
|
1167
|
+
optimisticPresented)
|
|
767
1168
|
) {
|
|
768
1169
|
// Content-hold commit, two triggers. Fully-prefetched nav: the payload
|
|
769
1170
|
// is fully resolved (forceAwait above), so the transition commits
|
|
770
|
-
// synchronously — no fallback flash.
|
|
1171
|
+
// synchronously — no fallback flash. Not a payload with holes: its
|
|
1172
|
+
// page is not whole, and a plain click to it commits urgently. In a
|
|
1173
|
+
// transition React starts a view transition for the commit wherever
|
|
1174
|
+
// a <ViewTransition> is mounted on the page being left, which the
|
|
1175
|
+
// plain click does not start, and what fills the holes is revealed
|
|
1176
|
+
// behind it (measured with a 600 ms view transition: the page
|
|
1177
|
+
// complete at 641 ms against 307). Same-structure nav: the re-run
|
|
771
1178
|
// loaders are still streaming, and an urgent commit would re-suspend
|
|
772
1179
|
// the ALREADY-REVEALED boundaries — replacing visible content with its
|
|
773
1180
|
// own fallback for the loader's full duration (the PLP filter-change
|
|
@@ -806,6 +1213,14 @@ export function createPartialUpdater(
|
|
|
806
1213
|
// branch above (the opt-in).
|
|
807
1214
|
onUpdate(update);
|
|
808
1215
|
}
|
|
1216
|
+
// In a transition, so where React holds the tree the clear is held
|
|
1217
|
+
// with it (client-urls/navigation.ts).
|
|
1218
|
+
presentation?.clear();
|
|
1219
|
+
|
|
1220
|
+
// The tree is committed and handed to React, which may hold it on a
|
|
1221
|
+
// hole read with no boundary: this navigation fills it now, from the
|
|
1222
|
+
// request that left before the render.
|
|
1223
|
+
if (holes) await fillHoles(holes, signal, url, gatedOff);
|
|
809
1224
|
|
|
810
1225
|
debugLog("[Browser] Navigation complete");
|
|
811
1226
|
return;
|
|
@@ -813,12 +1228,6 @@ export function createPartialUpdater(
|
|
|
813
1228
|
console.warn(`[Browser] Full update (fallback)`);
|
|
814
1229
|
|
|
815
1230
|
const segments = payload.metadata?.segments || [];
|
|
816
|
-
|
|
817
|
-
if (signal?.aborted) {
|
|
818
|
-
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
1231
|
const segmentIds = segments.map((s: ResolvedSegment) => s.id);
|
|
823
1232
|
|
|
824
1233
|
const fullGatedOff = decideGatedOff(segments, payload.metadata ?? {});
|
|
@@ -841,11 +1250,10 @@ export function createPartialUpdater(
|
|
|
841
1250
|
segments,
|
|
842
1251
|
fullGatedOff,
|
|
843
1252
|
);
|
|
844
|
-
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
845
1253
|
const fullUpdate: NavigationUpdate = {
|
|
846
1254
|
root: newTree,
|
|
847
1255
|
metadata: payload.metadata!,
|
|
848
|
-
scroll:
|
|
1256
|
+
...(fullScroll && { scroll: fullScroll }),
|
|
849
1257
|
};
|
|
850
1258
|
|
|
851
1259
|
if (process.env.NODE_ENV !== "production") {
|