@rangojs/router 0.22.1 → 0.23.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 +8 -3
- 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/navigation-transaction.d.ts +21 -7
- package/dist/types/browser/partial-update.d.ts +34 -5
- package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
- 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 +19 -9
- 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 +16 -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/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/errors.d.ts +5 -0
- package/dist/types/handles/deferred-resolution.d.ts +2 -1
- package/dist/types/handles/pushed-by.d.ts +23 -0
- package/dist/types/loader-store.d.ts +8 -0
- package/dist/types/prefetch-markers.d.ts +11 -0
- package/dist/types/route-definition/helpers-types.d.ts +27 -0
- package/dist/types/router/is-action.d.ts +2 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
- 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 +14 -1
- 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 +3 -3
- 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/run-loader.d.ts +2 -1
- 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 +38 -0
- package/dist/types/testing/vitest.d.ts +11 -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/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 +11 -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 +842 -308
- package/package.json +1 -1
- package/skills/caching/SKILL.md +42 -5
- package/skills/catalog.json +2 -2
- package/skills/client-urls/SKILL.md +113 -24
- package/skills/cloudflare/SKILL.md +14 -9
- package/skills/hooks/SKILL.md +11 -3
- package/skills/links/SKILL.md +24 -0
- package/skills/loader/SKILL.md +192 -1
- 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 +13 -0
- package/skills/prerender/SKILL.md +22 -8
- package/skills/rango/SKILL.md +12 -6
- package/skills/route/SKILL.md +139 -0
- package/skills/testing/SKILL.md +3 -2
- package/skills/testing/client-components.md +1 -1
- package/skills/testing/predicates.md +67 -32
- package/skills/testing/serve-shell-request.md +88 -18
- package/skills/testing/setup.md +2 -2
- package/skills/typesafety/route-types.md +2 -2
- package/skills/view-transitions/SKILL.md +26 -0
- package/src/browser/defer.ts +82 -0
- package/src/browser/document-revealed.ts +52 -0
- package/src/browser/event-controller.ts +43 -32
- package/src/browser/navigation-bridge.ts +64 -14
- 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 +528 -135
- package/src/browser/prefetch/fetch.ts +2 -1
- package/src/browser/react/NavigationProvider.tsx +47 -28
- package/src/browser/react/filter-segment-order.ts +13 -2
- package/src/browser/response-adapter.ts +20 -0
- package/src/browser/rsc-router.tsx +17 -45
- package/src/browser/server-action-bridge.ts +19 -63
- package/src/browser/types.ts +19 -9
- 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 +37 -10
- 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.ts +22 -1
- 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/errors.ts +15 -0
- package/src/handles/deferred-resolution.ts +28 -5
- package/src/handles/pushed-by.ts +122 -0
- package/src/loader-store.ts +18 -7
- package/src/prefetch-markers.ts +13 -0
- 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/router/intercept-resolution.ts +6 -4
- package/src/router/is-action.ts +2 -1
- package/src/router/loader-resolution.ts +83 -4
- package/src/router/match-api.ts +36 -1
- package/src/router/match-middleware/background-revalidation.ts +11 -1
- package/src/router/match-middleware/cache-lookup.ts +29 -6
- package/src/router/match-middleware/cache-store.ts +17 -1
- package/src/router/match-result.ts +14 -2
- package/src/router/navigation-snapshot.ts +2 -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/prefetch-deferral.ts +543 -0
- package/src/router/segment-resolution/revalidation.ts +169 -60
- package/src/rsc/rsc-rendering.ts +50 -7
- package/src/rsc/shell-capture.ts +2 -1
- 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/handle-store.ts +104 -8
- package/src/server/request-context.ts +21 -1
- package/src/server.ts +4 -0
- package/src/testing/action-form.ts +2 -11
- package/src/testing/e2e/page-helpers.ts +3 -1
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +6 -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/run-loader.ts +2 -1
- package/src/testing/run-revalidate.ts +142 -0
- package/src/testing/run-transition-when.ts +7 -8
- package/src/testing/serve-shell-request.ts +116 -6
- package/src/testing/vitest.ts +67 -1
- 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/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 +20 -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/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 +93 -32
- 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 { 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
|
/**
|
|
@@ -152,12 +206,14 @@ export type UpdateMode =
|
|
|
152
206
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
153
207
|
interceptSourceUrl?: string;
|
|
154
208
|
/**
|
|
155
|
-
* The
|
|
156
|
-
*
|
|
209
|
+
* The optimistic clientUrls() destination the bridge presented for
|
|
210
|
+
* this navigation. Transition-lane commits add
|
|
157
211
|
* OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
|
|
158
|
-
* boundaries do not animate the identical repaint
|
|
212
|
+
* boundaries do not animate the identical repaint, and `clear()` runs
|
|
213
|
+
* once the canonical tree is handed to React: `useOutlet().pending`
|
|
214
|
+
* ends with that commit, not with a `prefetch: false` fill after it.
|
|
159
215
|
*/
|
|
160
|
-
|
|
216
|
+
presentation?: { clear(): void };
|
|
161
217
|
/**
|
|
162
218
|
* A router.refresh() (or cross-tab refresh) of the current URL:
|
|
163
219
|
* transition({ when }) sees kind "revalidate", not "replace".
|
|
@@ -197,10 +253,287 @@ export function createPartialUpdater(
|
|
|
197
253
|
getVersion = () => undefined,
|
|
198
254
|
} = config;
|
|
199
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The segments the page on screen holds. A placeholder (`prefetch: false`,
|
|
258
|
+
* waiting for its fill) is not a copy of its segment: it never stands in
|
|
259
|
+
* for one in a reconcile, and its id is never sent as held (tx.commit below
|
|
260
|
+
* leaves it out of the store's ids).
|
|
261
|
+
*/
|
|
200
262
|
function getCurrentCachedSegments(): ResolvedSegment[] {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
263
|
+
return (entryOnScreen() || []).filter((s) => !isPlaceholder(s));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* One defer per placeholder: a loader's is its `loaderData`, any other
|
|
268
|
+
* segment's is its `component`. Every reader of a hole (Suspender,
|
|
269
|
+
* useLoader, React itself where a segment has no loading()) reads it as a
|
|
270
|
+
* stream that has not arrived yet, and all of them wait on its one promise
|
|
271
|
+
* (browser/defer.ts readDefer, asStream).
|
|
272
|
+
*/
|
|
273
|
+
function armHoles(
|
|
274
|
+
placeholders: ResolvedSegment[],
|
|
275
|
+
): Map<ResolvedSegment, Defer<unknown>> {
|
|
276
|
+
const defers = new Map<ResolvedSegment, Defer<unknown>>();
|
|
277
|
+
for (const segment of placeholders) {
|
|
278
|
+
const hole = new Defer<unknown>();
|
|
279
|
+
if (segment.type === "loader") segment.loaderData = hole;
|
|
280
|
+
else segment.component = hole as unknown as ReactNode;
|
|
281
|
+
defers.set(segment, hole);
|
|
282
|
+
}
|
|
283
|
+
return defers;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A defer that will not be resolved must not stay a pending stream. It is
|
|
288
|
+
* never rejected either: a rejection is unhandled in every aggregate built
|
|
289
|
+
* over it (segment-loader-promise.ts).
|
|
290
|
+
*/
|
|
291
|
+
function releaseHoles(holes: Holes): void {
|
|
292
|
+
for (const [placeholder, hole] of holes.defers) {
|
|
293
|
+
if (placeholder.type === "loader") {
|
|
294
|
+
store.loaders.releasePendingStream(placeholder.loaderId!, hole.promise);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The entry on screen, while it still holds these placeholders. By object,
|
|
301
|
+
* not by history key: a shallow navigation that copied the entry to a new
|
|
302
|
+
* key is still the page the answer is for, and an action refetch that
|
|
303
|
+
* already rendered the missing segments is not.
|
|
304
|
+
*/
|
|
305
|
+
function entryToFill(holes: Holes): ResolvedSegment[] | undefined {
|
|
306
|
+
const live = entryOnScreen();
|
|
307
|
+
return live?.some((s) => holes.defers.has(s)) ? live : undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The page on screen holds every one of `segments` (by id). */
|
|
311
|
+
function holdsAll(segments: ResolvedSegment[]): boolean {
|
|
312
|
+
const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
|
|
313
|
+
return segments.every((s) => onScreenIds.has(s.id));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function entryOnScreen(): ResolvedSegment[] | undefined {
|
|
317
|
+
return store.getCachedSegments(store.getHistoryKey())?.segments;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Act on the redirect a navigation was answered with. True when `metadata`
|
|
322
|
+
* carried one: the navigation is done here. A target in the app is thrown
|
|
323
|
+
* for the bridge to follow. An explicit off-host one
|
|
324
|
+
* (redirect(url, { external: true })) is a hard navigation, still
|
|
325
|
+
* scheme-validated (http/https only): `external` waives the same-origin
|
|
326
|
+
* check the app opted out of, not scheme safety, so a forged payload
|
|
327
|
+
* carrying a javascript: or data: URL cannot script via location.assign. A
|
|
328
|
+
* target the client refuses is dropped.
|
|
329
|
+
*/
|
|
330
|
+
function redirected(metadata: RscMetadata | undefined): boolean {
|
|
331
|
+
const redirect = metadata?.redirect;
|
|
332
|
+
if (!redirect) return false;
|
|
333
|
+
const target = validatePayloadRedirect(redirect);
|
|
334
|
+
if (!target) {
|
|
335
|
+
debugLog("[Browser] Ignoring blocked redirect payload");
|
|
336
|
+
} else if (redirect.external) {
|
|
337
|
+
debugLog("[Browser] External redirect (hard navigation)");
|
|
338
|
+
window.location.assign(target);
|
|
339
|
+
} else {
|
|
340
|
+
throw new ServerRedirect(target, metadata.locationState);
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Follow a redirect a fill was answered with: a replace navigation through
|
|
347
|
+
* the bridge, a document navigation for an external target or with no
|
|
348
|
+
* bridge. False when the client refuses the target.
|
|
349
|
+
*/
|
|
350
|
+
function followFillRedirect(
|
|
351
|
+
redirect: { url: string; external?: boolean },
|
|
352
|
+
state: Record<string, unknown> | undefined,
|
|
353
|
+
): boolean {
|
|
354
|
+
const target = validatePayloadRedirect(redirect);
|
|
355
|
+
if (!target) return false;
|
|
356
|
+
if (redirect.external || !config.fill) window.location.assign(target);
|
|
357
|
+
else config.fill.redirect(target, state);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Fill the holes of the tree this navigation committed with its fill's
|
|
363
|
+
* answer (`holes.answer`): a partial payload that carries the segment each placeholder
|
|
364
|
+
* stands for. Each defer resolves with that segment's value, the stored
|
|
365
|
+
* entry is rewritten with the segments themselves, and what the answer's
|
|
366
|
+
* handlers pushed and the location state they set go to the stores their
|
|
367
|
+
* readers subscribe to. React is handed nothing: the tree that reads the
|
|
368
|
+
* defers is the page, and what resolves reaches it as a stream that
|
|
369
|
+
* arrived.
|
|
370
|
+
*
|
|
371
|
+
* Called after tx.commit(), so the answer can never land before the tree it
|
|
372
|
+
* fills. The navigation's signal is the one thing that cancels it: a newer
|
|
373
|
+
* navigation or a back/forward aborts the request while it waits for its
|
|
374
|
+
* response (navigation-client.ts), and an answer that arrives after the
|
|
375
|
+
* abort is dropped. Either way the defers are released, not resolved.
|
|
376
|
+
*
|
|
377
|
+
* One answer does replace the page: one whose segments are not the ones the
|
|
378
|
+
* tree was built for. A handler that was awaited threw or called
|
|
379
|
+
* notFound(), and its entry's segments are one error segment instead
|
|
380
|
+
* (router/segment-resolution/helpers.ts catchSegmentError). That is the
|
|
381
|
+
* outcome of the navigation, and the segments are rendered as a
|
|
382
|
+
* navigation's are. `gatedOff` is the tree's transition({ when }) decision,
|
|
383
|
+
* for that render.
|
|
384
|
+
*
|
|
385
|
+
* Never rejects.
|
|
386
|
+
*/
|
|
387
|
+
async function fillHoles(
|
|
388
|
+
holes: Holes,
|
|
389
|
+
signal: AbortSignal | undefined,
|
|
390
|
+
url: string,
|
|
391
|
+
gatedOff: boolean,
|
|
392
|
+
): Promise<void> {
|
|
393
|
+
let filled = false;
|
|
394
|
+
try {
|
|
395
|
+
const result = await holes.answer;
|
|
396
|
+
// A page that no longer waits for the answer (an action refetch
|
|
397
|
+
// rendered what the placeholders stand for) takes nothing of it, its
|
|
398
|
+
// redirect included.
|
|
399
|
+
const live = entryToFill(holes);
|
|
400
|
+
if (signal?.aborted || !live) return;
|
|
401
|
+
|
|
402
|
+
const metadata = result.payload.metadata;
|
|
403
|
+
if (metadata?.redirect) {
|
|
404
|
+
if (!followFillRedirect(metadata.redirect, metadata.locationState)) {
|
|
405
|
+
throw new Error(FILL_REDIRECT_NOT_FOLLOWED);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!metadata?.isPartial) {
|
|
410
|
+
throw new Error("[rango] fill: not a partial payload");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const matched = metadata.matched || [];
|
|
414
|
+
const reconciled = reconcileSegments({
|
|
415
|
+
actor: "stale-revalidation",
|
|
416
|
+
matched,
|
|
417
|
+
diff: metadata.diff || [],
|
|
418
|
+
serverSegments: metadata.segments || [],
|
|
419
|
+
cachedSegments: live.filter((s) => !isPlaceholder(s)),
|
|
420
|
+
insertMissingDiff: true,
|
|
421
|
+
});
|
|
422
|
+
const missing = missingIds(matched, reconciled.segments);
|
|
423
|
+
if (missing.length > 0) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`[rango] fill: missing segments [${missing.join(", ")}]`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Nothing of the answer is handed over before this point: what an
|
|
430
|
+
// answer the client then refuses had set would stay under the error.
|
|
431
|
+
if (metadata.locationState) {
|
|
432
|
+
config.fill?.locationState(metadata.locationState);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const placeholders = [...holes.defers.keys()];
|
|
436
|
+
const answers = placeholders.map((placeholder) =>
|
|
437
|
+
reconciled.segments.find(
|
|
438
|
+
(s) => s.id === placeholder.id && s.type === placeholder.type,
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
if (
|
|
442
|
+
answers.includes(undefined) ||
|
|
443
|
+
matched.join() !== holes.matched.join()
|
|
444
|
+
) {
|
|
445
|
+
// The tree's handle stream (NavigationProvider processHandles) reads
|
|
446
|
+
// this array on every yield and drops the buckets of segments
|
|
447
|
+
// outside it.
|
|
448
|
+
replace(holes.matched, matched);
|
|
449
|
+
// Each place a hole stood is handed its defer again, resolved with
|
|
450
|
+
// what the answer put there, and its reader applies the read rule
|
|
451
|
+
// (browser/defer.ts): a reader that suspended on it while it mounted
|
|
452
|
+
// calls use() of the same promise, which React has marked; one React
|
|
453
|
+
// never read (a hole below another one) reads the value, so this
|
|
454
|
+
// urgent update shows no fallback the plain click does not show
|
|
455
|
+
// (navigation-adoption-loading.test.tsx, "a failed route's error").
|
|
456
|
+
const settled = new Map<ResolvedSegment, ResolvedSegment>();
|
|
457
|
+
for (const [placeholder, hole] of holes.defers) {
|
|
458
|
+
if (placeholder.type === "loader") continue;
|
|
459
|
+
// By id alone: an error segment takes its route's place.
|
|
460
|
+
const next = reconciled.mainSegments.find(
|
|
461
|
+
(s) => s.id === placeholder.id,
|
|
462
|
+
);
|
|
463
|
+
if (next && !(next.component instanceof Promise)) {
|
|
464
|
+
hole.resolve(next.component);
|
|
465
|
+
settled.set(next, {
|
|
466
|
+
...next,
|
|
467
|
+
component: hole as unknown as ReactNode,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const main = reconciled.mainSegments.map((s) => settled.get(s) ?? s);
|
|
472
|
+
const root = await renderSegments(main, {
|
|
473
|
+
transitionGatedOff: gatedOff,
|
|
474
|
+
interceptSegments:
|
|
475
|
+
reconciled.interceptSegments.length > 0
|
|
476
|
+
? reconciled.interceptSegments
|
|
477
|
+
: undefined,
|
|
478
|
+
});
|
|
479
|
+
if (signal?.aborted || entryOnScreen() !== live) return;
|
|
480
|
+
releaseHoles(holes);
|
|
481
|
+
// The navigation's outcome (see fillHoles), so its cause.
|
|
482
|
+
if (process.env.NODE_ENV !== "production") {
|
|
483
|
+
auditTreeCause("navigation");
|
|
484
|
+
}
|
|
485
|
+
// No `scroll`: the transaction decided it with the click's update.
|
|
486
|
+
// A decision here, even "do not scroll", would replace that pending
|
|
487
|
+
// one (NavigationProvider).
|
|
488
|
+
onUpdate({ root, metadata });
|
|
489
|
+
} else {
|
|
490
|
+
config.fill?.handles(metadata);
|
|
491
|
+
// All in one task, each with its own stream or node: a hole below
|
|
492
|
+
// another one is read in the render the outer one's resolution
|
|
493
|
+
// starts. Resolved by then with a node, it costs that render nothing
|
|
494
|
+
// where a router reader reads it (browser/defer.ts readDefer); with a
|
|
495
|
+
// stream, or where React reads the node itself, one microtask. Not a
|
|
496
|
+
// fallback.
|
|
497
|
+
placeholders.forEach((placeholder, index) => {
|
|
498
|
+
const next = answers[index]!;
|
|
499
|
+
holes.defers
|
|
500
|
+
.get(placeholder)!
|
|
501
|
+
.resolve(
|
|
502
|
+
placeholder.type === "loader" ? next.loaderData : next.component,
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The entry keeps the answer's own streams and nodes, not the defers:
|
|
508
|
+
// a defer belongs to the tree on screen, and the next render from this
|
|
509
|
+
// page builds on the entry. The answer belongs to the visit that built
|
|
510
|
+
// the tree: the entry is rewritten in place, not re-cached.
|
|
511
|
+
// cacheSegmentsForHistory would advance the store's nav instance and
|
|
512
|
+
// disown the tree's handle stream, and would reset the entry's stale
|
|
513
|
+
// flag.
|
|
514
|
+
replace(live, reconciled.segments);
|
|
515
|
+
store.setSegmentIds(matched);
|
|
516
|
+
filled = true;
|
|
517
|
+
} catch (thrown) {
|
|
518
|
+
// Only on a page that still waits for this answer.
|
|
519
|
+
if (signal?.aborted || !entryToFill(holes)) return;
|
|
520
|
+
let error = thrown;
|
|
521
|
+
if (error instanceof ServerRedirect) {
|
|
522
|
+
if (followFillRedirect({ url: error.url }, error.state)) return;
|
|
523
|
+
error = new Error(FILL_REDIRECT_NOT_FOLLOWED);
|
|
524
|
+
}
|
|
525
|
+
console.error("[rango] fill failed:", error);
|
|
526
|
+
// Shown the way a navigation the client cannot process is
|
|
527
|
+
// (navigation-bridge.ts): the error replaces the page, so an error
|
|
528
|
+
// boundary takes over and no fallback is left waiting.
|
|
529
|
+
emitNavigationError(
|
|
530
|
+
onUpdate,
|
|
531
|
+
toNetworkError(error, { url, operation: "navigation" }) ?? error,
|
|
532
|
+
url,
|
|
533
|
+
);
|
|
534
|
+
} finally {
|
|
535
|
+
if (!filled) releaseHoles(holes);
|
|
536
|
+
}
|
|
204
537
|
}
|
|
205
538
|
|
|
206
539
|
async function fetchPartialUpdate(
|
|
@@ -316,31 +649,6 @@ export function createPartialUpdater(
|
|
|
316
649
|
);
|
|
317
650
|
}
|
|
318
651
|
|
|
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
652
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
345
653
|
fetchResult = await client.fetchPartial({
|
|
346
654
|
targetUrl: url,
|
|
@@ -348,7 +656,6 @@ export function createPartialUpdater(
|
|
|
348
656
|
previousUrl,
|
|
349
657
|
staleRevalidation:
|
|
350
658
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
351
|
-
clientRevalidation,
|
|
352
659
|
version: getVersion(),
|
|
353
660
|
routerId: store.getRouterId?.(),
|
|
354
661
|
});
|
|
@@ -363,7 +670,26 @@ export function createPartialUpdater(
|
|
|
363
670
|
// Side effect only: end the streaming token once the stream settles.
|
|
364
671
|
// The wrapped promise was never read as a value; only the .end() matters.
|
|
365
672
|
// The .catch keeps an unhandled rejection from leaking if the stream errors.
|
|
366
|
-
|
|
673
|
+
// A payload with deferred segments is still streaming until its fill has
|
|
674
|
+
// (`filled` is set below, before this function first awaits). A
|
|
675
|
+
// navigation that is left ignores its response, which is never cancelled
|
|
676
|
+
// (navigation-client.ts): it streams to its end, and stops counting as
|
|
677
|
+
// streaming at the abort.
|
|
678
|
+
let filled: Promise<unknown> | undefined;
|
|
679
|
+
const endStream = (): void => {
|
|
680
|
+
signal?.removeEventListener("abort", endStream);
|
|
681
|
+
streamingToken.end();
|
|
682
|
+
};
|
|
683
|
+
signal?.addEventListener("abort", endStream);
|
|
684
|
+
rawStreamComplete
|
|
685
|
+
.then(() => (filled ? filled.then(endStream) : endStream()))
|
|
686
|
+
.catch(() => {});
|
|
687
|
+
|
|
688
|
+
if (signal?.aborted) {
|
|
689
|
+
endStream();
|
|
690
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
367
693
|
|
|
368
694
|
const currentRouterId = store.getRouterId?.();
|
|
369
695
|
if (
|
|
@@ -379,49 +705,12 @@ export function createPartialUpdater(
|
|
|
379
705
|
return;
|
|
380
706
|
}
|
|
381
707
|
|
|
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
|
-
}
|
|
708
|
+
if (redirected(payload.metadata)) return;
|
|
416
709
|
|
|
417
710
|
if (payload.metadata?.isPartial) {
|
|
418
711
|
const { segments: newSegments, matched, diff } = payload.metadata;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (signal?.aborted) {
|
|
422
|
-
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
712
|
+
let serverSegments = newSegments || [];
|
|
713
|
+
let sent = diff || [];
|
|
425
714
|
|
|
426
715
|
if (IS_BROWSER_DEBUG) {
|
|
427
716
|
debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
|
|
@@ -448,6 +737,7 @@ export function createPartialUpdater(
|
|
|
448
737
|
forceAwait: true,
|
|
449
738
|
transitionGatedOff: cachedGatedOff,
|
|
450
739
|
});
|
|
740
|
+
if (signal?.aborted) return;
|
|
451
741
|
|
|
452
742
|
const { scroll: commitScroll } = tx.commit(
|
|
453
743
|
matchedIds,
|
|
@@ -470,7 +760,7 @@ export function createPartialUpdater(
|
|
|
470
760
|
...metadataWithoutHandles,
|
|
471
761
|
cachedHandleData: mode.targetCacheHandleData,
|
|
472
762
|
},
|
|
473
|
-
scroll:
|
|
763
|
+
...(commitScroll && { scroll: commitScroll }),
|
|
474
764
|
};
|
|
475
765
|
|
|
476
766
|
if (process.env.NODE_ENV !== "production") {
|
|
@@ -500,6 +790,7 @@ export function createPartialUpdater(
|
|
|
500
790
|
const newTree = await renderSegments(existingSegments, {
|
|
501
791
|
forceAwait: true,
|
|
502
792
|
});
|
|
793
|
+
if (signal?.aborted) return;
|
|
503
794
|
|
|
504
795
|
const { scroll: leaveScroll } = tx.commit(
|
|
505
796
|
matchedIds,
|
|
@@ -513,7 +804,7 @@ export function createPartialUpdater(
|
|
|
513
804
|
onUpdate({
|
|
514
805
|
root: newTree,
|
|
515
806
|
metadata: payload.metadata,
|
|
516
|
-
scroll:
|
|
807
|
+
...(leaveScroll && { scroll: leaveScroll }),
|
|
517
808
|
});
|
|
518
809
|
|
|
519
810
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -531,33 +822,49 @@ export function createPartialUpdater(
|
|
|
531
822
|
return;
|
|
532
823
|
}
|
|
533
824
|
|
|
534
|
-
|
|
825
|
+
let matchedIds = matched || [];
|
|
535
826
|
const actor: ReconcileActor =
|
|
536
827
|
mode.type === "stale-revalidation" || mode.type === "action"
|
|
537
828
|
? "stale-revalidation"
|
|
538
829
|
: "navigation";
|
|
539
830
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
831
|
+
// prefetch: false. A payload that carries deferred segments (an adopted
|
|
832
|
+
// prefetch, whatever answered it) is the page with holes in it: one
|
|
833
|
+
// placeholder per segment whose work did not run, each with what its
|
|
834
|
+
// wrapper chain is built from. The tree is built once, with a deferred
|
|
835
|
+
// in place of each missing value, and this navigation fills them from
|
|
836
|
+
// one fill request once it has committed the tree.
|
|
837
|
+
let defers: Holes["defers"] | undefined;
|
|
838
|
+
let heldIds = matchedIds;
|
|
839
|
+
const placeholders = serverSegments.filter(isPlaceholder);
|
|
840
|
+
if (placeholders.length > 0) {
|
|
841
|
+
defers = armHoles(placeholders);
|
|
842
|
+
// A placeholder is not held: every later request from this page
|
|
843
|
+
// leaves its id out, so the server renders it.
|
|
844
|
+
const deferredIds = new Set(placeholders.map((s) => s.id));
|
|
845
|
+
heldIds = matchedIds.filter((id) => !deferredIds.has(id));
|
|
846
|
+
}
|
|
548
847
|
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
848
|
+
const reconcile = () =>
|
|
849
|
+
reconcileSegments({
|
|
850
|
+
actor,
|
|
851
|
+
matched: matchedIds,
|
|
852
|
+
diff: sent,
|
|
853
|
+
serverSegments,
|
|
854
|
+
cachedSegments: cachedSegs,
|
|
855
|
+
insertMissingDiff: true,
|
|
856
|
+
});
|
|
857
|
+
let reconciled = reconcile();
|
|
553
858
|
|
|
554
|
-
|
|
555
|
-
|
|
859
|
+
const missing = missingIds(matchedIds, reconciled.segments);
|
|
860
|
+
|
|
861
|
+
if (missing.length > 0) {
|
|
862
|
+
const missingCount = missing.length;
|
|
556
863
|
|
|
557
864
|
if (isRetry) {
|
|
558
|
-
console.warn("Missing ids", { missingIds });
|
|
865
|
+
console.warn("Missing ids", { missingIds: missing });
|
|
559
866
|
throw new Error(
|
|
560
|
-
`[Browser] Failed to fetch segments after retry. Missing: [${
|
|
867
|
+
`[Browser] Failed to fetch segments after retry. Missing: [${missing.join(", ")}]`,
|
|
561
868
|
);
|
|
562
869
|
}
|
|
563
870
|
if (signal?.aborted) {
|
|
@@ -593,6 +900,81 @@ export function createPartialUpdater(
|
|
|
593
900
|
// reach renderSegments. The response carries no decision, so a reused
|
|
594
901
|
// prefetch is decided against the real source.
|
|
595
902
|
const gatedOff = decideGatedOff(reconciled.segments, payload.metadata);
|
|
903
|
+
|
|
904
|
+
// The fill: one request for what the prefetch deferred, listing what
|
|
905
|
+
// the client holds once this payload commits. It leaves now, before
|
|
906
|
+
// the tree is rendered, with this navigation's signal.
|
|
907
|
+
let holes: Holes | undefined;
|
|
908
|
+
if (defers) {
|
|
909
|
+
holes = {
|
|
910
|
+
defers,
|
|
911
|
+
matched: matchedIds,
|
|
912
|
+
answer: client.fetchPartial({
|
|
913
|
+
targetUrl: url,
|
|
914
|
+
segmentIds: heldIds,
|
|
915
|
+
// The page the fill completes is the page it is on.
|
|
916
|
+
previousUrl: url,
|
|
917
|
+
fill: true,
|
|
918
|
+
signal,
|
|
919
|
+
version: getVersion(),
|
|
920
|
+
routerId: store.getRouterId?.(),
|
|
921
|
+
}),
|
|
922
|
+
};
|
|
923
|
+
filled = holes.answer
|
|
924
|
+
.then((answer) => answer.streamComplete)
|
|
925
|
+
.catch(() => {});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// What a fill brought for a page that waited for it (below).
|
|
929
|
+
let waited: RscMetadata | undefined;
|
|
930
|
+
if (
|
|
931
|
+
holes &&
|
|
932
|
+
placeholders.some((s) => s.type !== "loader") &&
|
|
933
|
+
shouldStartViewTransition(reconciled.segments, gatedOff)
|
|
934
|
+
) {
|
|
935
|
+
// A deferred handler on a page that commits in a transition. React
|
|
936
|
+
// renders such a page's content in the commit that shows its
|
|
937
|
+
// fallback, in a render that can wait: a plain click commits it with
|
|
938
|
+
// its response, URL, hooks and page together. Committed with the
|
|
939
|
+
// click and filled afterwards, the URL moved a round trip before the
|
|
940
|
+
// page, and no later commit could bring the content in without a
|
|
941
|
+
// view transition the plain click does not start (measured: three
|
|
942
|
+
// for two, the content up to 280 ms late). So this click is a
|
|
943
|
+
// navigation that waits for its fill: nothing commits until the
|
|
944
|
+
// fill's first chunk, and then the page commits once, from the
|
|
945
|
+
// prefetched segments and the fill's, with no hole in it.
|
|
946
|
+
const { answer } = holes;
|
|
947
|
+
holes = undefined;
|
|
948
|
+
waited = (await answer).payload.metadata;
|
|
949
|
+
if (signal?.aborted || redirected(waited)) return;
|
|
950
|
+
if (waited?.isPartial) {
|
|
951
|
+
matchedIds = heldIds = waited.matched || [];
|
|
952
|
+
serverSegments = [
|
|
953
|
+
...serverSegments.filter((s) => !isPlaceholder(s)),
|
|
954
|
+
...(waited.segments || []),
|
|
955
|
+
];
|
|
956
|
+
sent = [...sent, ...(waited.diff || [])];
|
|
957
|
+
reconciled = reconcile();
|
|
958
|
+
if (waited.locationState) {
|
|
959
|
+
payload.metadata.locationState = {
|
|
960
|
+
...payload.metadata.locationState,
|
|
961
|
+
...waited.locationState,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// The two answers do not make the page (the routes changed between
|
|
966
|
+
// the prefetch and the click): start over with nothing held, as a
|
|
967
|
+
// navigation whose own response misses segments does above. That
|
|
968
|
+
// request is never answered from a prefetch, so it defers nothing.
|
|
969
|
+
if (
|
|
970
|
+
!waited?.isPartial ||
|
|
971
|
+
missingIds(matchedIds, reconciled.segments).length > 0
|
|
972
|
+
) {
|
|
973
|
+
return fetchPartialUpdate(url, [], true, signal, tx, mode);
|
|
974
|
+
}
|
|
975
|
+
} else if (holes) {
|
|
976
|
+
config.fill?.flush();
|
|
977
|
+
}
|
|
596
978
|
const renderOptions = {
|
|
597
979
|
transitionGatedOff: gatedOff,
|
|
598
980
|
isAction: mode.type === "action",
|
|
@@ -600,8 +982,10 @@ export function createPartialUpdater(
|
|
|
600
982
|
// land without a loading()/fallback frame. A fully-prefetched nav has
|
|
601
983
|
// its router data already resolved (the prefetch stream drained), so
|
|
602
984
|
// awaiting it here is free; the commit below then runs in a transition
|
|
603
|
-
// (fullyPrefetched branch) so nothing router-owned can flash.
|
|
604
|
-
|
|
985
|
+
// (fullyPrefetched branch) so nothing router-owned can flash. A page
|
|
986
|
+
// that waited for its fill is still streaming it.
|
|
987
|
+
forceAwait:
|
|
988
|
+
mode.type === "stale-revalidation" || (fullyPrefetched && !waited),
|
|
605
989
|
interceptSegments:
|
|
606
990
|
reconciled.interceptSegments.length > 0
|
|
607
991
|
? reconciled.interceptSegments
|
|
@@ -657,10 +1041,8 @@ export function createPartialUpdater(
|
|
|
657
1041
|
// cache, which says nothing about what is visible now. Restricted to
|
|
658
1042
|
// plain navigations — leave-intercept must close its modal urgently,
|
|
659
1043
|
// and action/stale modes have their own transition branch.
|
|
660
|
-
const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
|
|
661
1044
|
const isSameStructureNav =
|
|
662
|
-
mode.type === "navigate" &&
|
|
663
|
-
reconciled.segments.every((s) => onScreenIds.has(s.id));
|
|
1045
|
+
mode.type === "navigate" && holdsAll(reconciled.segments);
|
|
664
1046
|
|
|
665
1047
|
const effectiveInterceptSource =
|
|
666
1048
|
interceptSourceUrl || segmentState.currentUrl;
|
|
@@ -672,7 +1054,6 @@ export function createPartialUpdater(
|
|
|
672
1054
|
}
|
|
673
1055
|
}
|
|
674
1056
|
|
|
675
|
-
const allSegmentIds = matchedIds;
|
|
676
1057
|
const serverLocationState = payload.metadata?.locationState;
|
|
677
1058
|
const overrides: CommitOverrides = {
|
|
678
1059
|
routeName: payload.metadata?.routeName,
|
|
@@ -684,10 +1065,16 @@ export function createPartialUpdater(
|
|
|
684
1065
|
...(serverLocationState && { serverState: serverLocationState }),
|
|
685
1066
|
};
|
|
686
1067
|
const { scroll: navScroll } = tx.commit(
|
|
687
|
-
|
|
1068
|
+
heldIds,
|
|
688
1069
|
reconciled.segments,
|
|
689
1070
|
overrides,
|
|
690
1071
|
);
|
|
1072
|
+
const hasTransition = shouldStartViewTransition(
|
|
1073
|
+
reconciled.segments,
|
|
1074
|
+
gatedOff,
|
|
1075
|
+
);
|
|
1076
|
+
// After tx.commit(): the handle stream belongs to the entry it made.
|
|
1077
|
+
if (waited) config.fill?.handles(waited);
|
|
691
1078
|
|
|
692
1079
|
if (mode.type === "stale-revalidation") {
|
|
693
1080
|
const historyKeyNow = store.getHistoryKey();
|
|
@@ -701,12 +1088,9 @@ export function createPartialUpdater(
|
|
|
701
1088
|
|
|
702
1089
|
debugLog("[partial-update] updating document");
|
|
703
1090
|
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
);
|
|
708
|
-
const optimisticPresented =
|
|
709
|
-
mode.type === "navigate" && mode.optimisticPresented === true;
|
|
1091
|
+
const presentation =
|
|
1092
|
+
mode.type === "navigate" ? mode.presentation : undefined;
|
|
1093
|
+
const optimisticPresented = presentation !== undefined;
|
|
710
1094
|
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
|
|
711
1095
|
// segment still carries a transition after the server-side when-gate, and
|
|
712
1096
|
// whether the commit will be held in a startTransition. If `withTransition`
|
|
@@ -730,12 +1114,12 @@ export function createPartialUpdater(
|
|
|
730
1114
|
// metadata); popstate navigations get no optimistic presentation anyway.
|
|
731
1115
|
setActiveInterceptTargets(payload.metadata?.interceptTargets);
|
|
732
1116
|
|
|
733
|
-
|
|
734
|
-
|
|
1117
|
+
// No scroll for a commit that is not a navigation (an action refetch):
|
|
1118
|
+
// navigation-transaction.ts CommitResult.
|
|
735
1119
|
const update: NavigationUpdate = {
|
|
736
1120
|
root: newTree,
|
|
737
1121
|
metadata: payload.metadata!,
|
|
738
|
-
scroll:
|
|
1122
|
+
...(navScroll && { scroll: navScroll }),
|
|
739
1123
|
};
|
|
740
1124
|
if (process.env.NODE_ENV !== "production") {
|
|
741
1125
|
auditTreeCause(treeCause(mode, tx.traversal));
|
|
@@ -763,11 +1147,19 @@ export function createPartialUpdater(
|
|
|
763
1147
|
);
|
|
764
1148
|
} else if (
|
|
765
1149
|
!gatedOff &&
|
|
766
|
-
(fullyPrefetched
|
|
1150
|
+
((fullyPrefetched && !holes) ||
|
|
1151
|
+
isSameStructureNav ||
|
|
1152
|
+
optimisticPresented)
|
|
767
1153
|
) {
|
|
768
1154
|
// Content-hold commit, two triggers. Fully-prefetched nav: the payload
|
|
769
1155
|
// is fully resolved (forceAwait above), so the transition commits
|
|
770
|
-
// synchronously — no fallback flash.
|
|
1156
|
+
// synchronously — no fallback flash. Not a payload with holes: its
|
|
1157
|
+
// page is not whole, and a plain click to it commits urgently. In a
|
|
1158
|
+
// transition React starts a view transition for the commit wherever
|
|
1159
|
+
// a <ViewTransition> is mounted on the page being left, which the
|
|
1160
|
+
// plain click does not start, and what fills the holes is revealed
|
|
1161
|
+
// behind it (measured with a 600 ms view transition: the page
|
|
1162
|
+
// complete at 641 ms against 307). Same-structure nav: the re-run
|
|
771
1163
|
// loaders are still streaming, and an urgent commit would re-suspend
|
|
772
1164
|
// the ALREADY-REVEALED boundaries — replacing visible content with its
|
|
773
1165
|
// own fallback for the loader's full duration (the PLP filter-change
|
|
@@ -806,6 +1198,14 @@ export function createPartialUpdater(
|
|
|
806
1198
|
// branch above (the opt-in).
|
|
807
1199
|
onUpdate(update);
|
|
808
1200
|
}
|
|
1201
|
+
// In a transition, so where React holds the tree the clear is held
|
|
1202
|
+
// with it (client-urls/navigation.ts).
|
|
1203
|
+
presentation?.clear();
|
|
1204
|
+
|
|
1205
|
+
// The tree is committed and handed to React, which may hold it on a
|
|
1206
|
+
// hole read with no boundary: this navigation fills it now, from the
|
|
1207
|
+
// request that left before the render.
|
|
1208
|
+
if (holes) await fillHoles(holes, signal, url, gatedOff);
|
|
809
1209
|
|
|
810
1210
|
debugLog("[Browser] Navigation complete");
|
|
811
1211
|
return;
|
|
@@ -813,12 +1213,6 @@ export function createPartialUpdater(
|
|
|
813
1213
|
console.warn(`[Browser] Full update (fallback)`);
|
|
814
1214
|
|
|
815
1215
|
const segments = payload.metadata?.segments || [];
|
|
816
|
-
|
|
817
|
-
if (signal?.aborted) {
|
|
818
|
-
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
1216
|
const segmentIds = segments.map((s: ResolvedSegment) => s.id);
|
|
823
1217
|
|
|
824
1218
|
const fullGatedOff = decideGatedOff(segments, payload.metadata ?? {});
|
|
@@ -841,11 +1235,10 @@ export function createPartialUpdater(
|
|
|
841
1235
|
segments,
|
|
842
1236
|
fullGatedOff,
|
|
843
1237
|
);
|
|
844
|
-
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
845
1238
|
const fullUpdate: NavigationUpdate = {
|
|
846
1239
|
root: newTree,
|
|
847
1240
|
metadata: payload.metadata!,
|
|
848
|
-
scroll:
|
|
1241
|
+
...(fullScroll && { scroll: fullScroll }),
|
|
849
1242
|
};
|
|
850
1243
|
|
|
851
1244
|
if (process.env.NODE_ENV !== "production") {
|