@rangojs/router 0.0.0-experimental.10 → 0.0.0-experimental.100
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/AGENTS.md +9 -0
- package/README.md +1037 -4
- package/dist/bin/rango.js +1619 -157
- package/dist/vite/index.js +5762 -2301
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +71 -63
- package/skills/breadcrumbs/SKILL.md +252 -0
- package/skills/cache-guide/SKILL.md +294 -0
- package/skills/caching/SKILL.md +93 -23
- package/skills/composability/SKILL.md +172 -0
- package/skills/debug-manifest/SKILL.md +12 -8
- package/skills/document-cache/SKILL.md +18 -16
- package/skills/fonts/SKILL.md +6 -4
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +367 -71
- package/skills/host-router/SKILL.md +218 -0
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +176 -8
- package/skills/layout/SKILL.md +124 -3
- package/skills/links/SKILL.md +304 -25
- package/skills/loader/SKILL.md +474 -47
- package/skills/middleware/SKILL.md +207 -37
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +15 -11
- package/skills/parallel/SKILL.md +272 -1
- package/skills/prerender/SKILL.md +467 -65
- package/skills/rango/SKILL.md +89 -21
- package/skills/response-routes/SKILL.md +152 -91
- package/skills/route/SKILL.md +305 -14
- package/skills/router-setup/SKILL.md +210 -32
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/theme/SKILL.md +9 -8
- package/skills/typesafety/SKILL.md +333 -86
- package/skills/use-cache/SKILL.md +324 -0
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/__internal.ts +102 -4
- package/src/bin/rango.ts +312 -15
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/action-response-classifier.ts +99 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +136 -68
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/link-interceptor.ts +24 -4
- package/src/browser/logging.ts +55 -0
- package/src/browser/merge-segment-loaders.ts +20 -12
- package/src/browser/navigation-bridge.ts +374 -561
- package/src/browser/navigation-client.ts +228 -70
- package/src/browser/navigation-store.ts +97 -55
- package/src/browser/navigation-transaction.ts +297 -0
- package/src/browser/network-error-handler.ts +61 -0
- package/src/browser/partial-update.ts +376 -315
- package/src/browser/prefetch/cache.ts +314 -0
- package/src/browser/prefetch/fetch.ts +282 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +48 -0
- package/src/browser/prefetch/queue.ts +191 -0
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +152 -0
- package/src/browser/react/Link.tsx +255 -71
- package/src/browser/react/NavigationProvider.tsx +152 -24
- package/src/browser/react/context.ts +11 -0
- package/src/browser/react/filter-segment-order.ts +55 -0
- package/src/browser/react/index.ts +15 -12
- package/src/browser/react/location-state-shared.ts +95 -53
- package/src/browser/react/location-state.ts +60 -15
- package/src/browser/react/mount-context.ts +6 -1
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +29 -51
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +30 -120
- package/src/browser/react/use-link-status.ts +6 -5
- package/src/browser/react/use-navigation.ts +44 -65
- package/src/browser/react/use-params.ts +78 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +83 -0
- package/src/browser/react/use-search-params.ts +56 -0
- package/src/browser/react/use-segments.ts +85 -99
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +246 -64
- package/src/browser/scroll-restoration.ts +127 -52
- package/src/browser/segment-reconciler.ts +243 -0
- package/src/browser/segment-structure-assert.ts +16 -0
- package/src/browser/server-action-bridge.ts +510 -603
- package/src/browser/shallow.ts +6 -1
- package/src/browser/types.ts +158 -48
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +84 -23
- package/src/build/generate-route-types.ts +39 -828
- package/src/build/index.ts +4 -5
- package/src/build/route-trie.ts +85 -32
- package/src/build/route-types/ast-helpers.ts +25 -0
- package/src/build/route-types/ast-route-extraction.ts +98 -0
- package/src/build/route-types/codegen.ts +102 -0
- package/src/build/route-types/include-resolution.ts +418 -0
- package/src/build/route-types/param-extraction.ts +48 -0
- package/src/build/route-types/per-module-writer.ts +128 -0
- package/src/build/route-types/router-processing.ts +618 -0
- package/src/build/route-types/scan-filter.ts +85 -0
- package/src/build/runtime-discovery.ts +231 -0
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +342 -0
- package/src/cache/cache-scope.ts +167 -307
- package/src/cache/cf/cf-cache-store.ts +573 -21
- package/src/cache/cf/index.ts +13 -3
- package/src/cache/document-cache.ts +116 -77
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +41 -0
- package/src/cache/index.ts +1 -15
- package/src/cache/memory-segment-store.ts +191 -13
- package/src/cache/profile-registry.ts +73 -0
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +256 -0
- package/src/cache/taint.ts +153 -0
- package/src/cache/types.ts +72 -122
- package/src/client.rsc.tsx +6 -1
- package/src/client.tsx +118 -302
- package/src/component-utils.ts +4 -4
- package/src/components/DefaultDocument.tsx +5 -1
- package/src/context-var.ts +156 -0
- package/src/debug.ts +19 -9
- package/src/errors.ts +77 -7
- package/src/handle.ts +55 -10
- package/src/handles/MetaTags.tsx +73 -20
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/handles/meta.ts +30 -13
- package/src/host/cookie-handler.ts +21 -15
- package/src/host/errors.ts +8 -8
- package/src/host/index.ts +4 -7
- package/src/host/pattern-matcher.ts +27 -27
- package/src/host/router.ts +61 -39
- package/src/host/testing.ts +8 -8
- package/src/host/types.ts +15 -7
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +65 -45
- package/src/index.rsc.ts +138 -21
- package/src/index.ts +206 -51
- package/src/internal-debug.ts +11 -0
- package/src/loader.rsc.ts +25 -143
- package/src/loader.ts +27 -10
- package/src/network-error-thrower.tsx +3 -1
- package/src/outlet-context.ts +1 -1
- package/src/outlet-provider.tsx +45 -0
- package/src/prerender/param-hash.ts +4 -2
- package/src/prerender/store.ts +159 -13
- package/src/prerender.ts +397 -29
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +231 -121
- package/src/root-error-boundary.tsx +41 -29
- package/src/route-content-wrapper.tsx +7 -4
- package/src/route-definition/dsl-helpers.ts +1134 -0
- package/src/route-definition/helper-factories.ts +200 -0
- package/src/route-definition/helpers-types.ts +483 -0
- package/src/route-definition/index.ts +55 -0
- package/src/route-definition/redirect.ts +101 -0
- package/src/route-definition/resolve-handler-use.ts +155 -0
- package/src/route-definition.ts +1 -1431
- package/src/route-map-builder.ts +162 -123
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +66 -9
- package/src/router/content-negotiation.ts +215 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +9 -9
- package/src/router/find-match.ts +160 -0
- package/src/router/handler-context.ts +418 -86
- package/src/router/intercept-resolution.ts +35 -20
- package/src/router/lazy-includes.ts +237 -0
- package/src/router/loader-resolution.ts +359 -128
- package/src/router/logging.ts +251 -0
- package/src/router/manifest.ts +98 -32
- package/src/router/match-api.ts +196 -261
- package/src/router/match-context.ts +4 -2
- package/src/router/match-handlers.ts +441 -0
- package/src/router/match-middleware/background-revalidation.ts +108 -93
- package/src/router/match-middleware/cache-lookup.ts +415 -86
- package/src/router/match-middleware/cache-store.ts +91 -29
- package/src/router/match-middleware/intercept-resolution.ts +48 -21
- package/src/router/match-middleware/segment-resolution.ts +73 -9
- package/src/router/match-pipelines.ts +10 -45
- package/src/router/match-result.ts +154 -35
- package/src/router/metrics.ts +240 -15
- package/src/router/middleware-cookies.ts +55 -0
- package/src/router/middleware-types.ts +209 -0
- package/src/router/middleware.ts +373 -371
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +292 -52
- package/src/router/prerender-match.ts +502 -0
- package/src/router/preview-match.ts +98 -0
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +152 -39
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +41 -21
- package/src/router/router-interfaces.ts +484 -0
- package/src/router/router-options.ts +618 -0
- package/src/router/router-registry.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +756 -0
- package/src/router/segment-resolution/helpers.ts +268 -0
- package/src/router/segment-resolution/loader-cache.ts +199 -0
- package/src/router/segment-resolution/revalidation.ts +1407 -0
- package/src/router/segment-resolution/static-store.ts +67 -0
- package/src/router/segment-resolution.ts +21 -1315
- package/src/router/segment-wrappers.ts +291 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/trie-matching.ts +111 -39
- package/src/router/types.ts +17 -9
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +642 -2011
- package/src/rsc/handler-context.ts +45 -0
- package/src/rsc/handler.ts +864 -1114
- package/src/rsc/helpers.ts +181 -19
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +229 -0
- package/src/rsc/manifest-init.ts +90 -0
- package/src/rsc/nonce.ts +14 -0
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +395 -0
- package/src/rsc/response-error.ts +37 -0
- package/src/rsc/response-route-handler.ts +360 -0
- package/src/rsc/rsc-rendering.ts +256 -0
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +360 -0
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +52 -11
- package/src/search-params.ts +230 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +187 -38
- package/src/server/context.ts +333 -59
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +37 -0
- package/src/server/handle-store.ts +113 -15
- package/src/server/loader-registry.ts +24 -64
- package/src/server/request-context.ts +603 -109
- package/src/server.ts +35 -155
- package/src/ssr/index.tsx +107 -30
- package/src/static-handler.ts +126 -0
- package/src/theme/ThemeProvider.tsx +21 -15
- package/src/theme/ThemeScript.tsx +5 -5
- package/src/theme/constants.ts +5 -2
- package/src/theme/index.ts +4 -14
- package/src/theme/theme-context.ts +4 -30
- package/src/theme/theme-script.ts +21 -18
- package/src/types/boundaries.ts +158 -0
- package/src/types/cache-types.ts +198 -0
- package/src/types/error-types.ts +192 -0
- package/src/types/global-namespace.ts +100 -0
- package/src/types/handler-context.ts +764 -0
- package/src/types/index.ts +88 -0
- package/src/types/loader-types.ts +209 -0
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-config.ts +170 -0
- package/src/types/route-entry.ts +120 -0
- package/src/types/segments.ts +167 -0
- package/src/types.ts +1 -1757
- package/src/urls/include-helper.ts +207 -0
- package/src/urls/index.ts +53 -0
- package/src/urls/path-helper-types.ts +372 -0
- package/src/urls/path-helper.ts +364 -0
- package/src/urls/pattern-types.ts +107 -0
- package/src/urls/response-types.ts +108 -0
- package/src/urls/type-extraction.ts +372 -0
- package/src/urls/urls-function.ts +98 -0
- package/src/urls.ts +1 -1282
- package/src/use-loader.tsx +161 -81
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/bundle-postprocess.ts +181 -0
- package/src/vite/discovery/discover-routers.ts +376 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +486 -0
- package/src/vite/discovery/route-types-writer.ts +258 -0
- package/src/vite/discovery/self-gen-tracking.ts +73 -0
- package/src/vite/discovery/state.ts +117 -0
- package/src/vite/discovery/virtual-module-codegen.ts +203 -0
- package/src/vite/index.ts +15 -2063
- package/src/vite/plugin-types.ts +103 -0
- package/src/vite/plugins/cjs-to-esm.ts +98 -0
- package/src/vite/plugins/client-ref-dedup.ts +131 -0
- package/src/vite/plugins/client-ref-hashing.ts +117 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +107 -64
- package/src/vite/plugins/expose-id-utils.ts +299 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
- package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +127 -0
- package/src/vite/plugins/expose-ids/types.ts +45 -0
- package/src/vite/plugins/expose-internal-ids.ts +816 -0
- package/src/vite/plugins/performance-tracks.ts +96 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/use-cache-transform.ts +336 -0
- package/src/vite/plugins/version-injector.ts +109 -0
- package/src/vite/plugins/version-plugin.ts +266 -0
- package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +497 -0
- package/src/vite/router-discovery.ts +1423 -0
- package/src/vite/utils/ast-handler-extract.ts +517 -0
- package/src/vite/utils/banner.ts +36 -0
- package/src/vite/utils/bundle-analysis.ts +137 -0
- package/src/vite/utils/manifest-utils.ts +70 -0
- package/src/vite/utils/package-resolution.ts +161 -0
- package/src/vite/utils/prerender-utils.ts +222 -0
- package/src/vite/utils/shared-utils.ts +170 -0
- package/CLAUDE.md +0 -43
- package/src/browser/lru-cache.ts +0 -69
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/urls.gen.ts +0 -8
- package/src/vite/expose-handle-id.ts +0 -209
- package/src/vite/expose-loader-id.ts +0 -426
- package/src/vite/expose-location-state-id.ts +0 -177
- package/src/vite/expose-prerender-handler-id.ts +0 -429
- package/src/vite/package-resolution.ts +0 -125
- /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
|
@@ -5,15 +5,48 @@ import type {
|
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
|
+
import * as React from "react";
|
|
8
9
|
import { startTransition } from "react";
|
|
10
|
+
|
|
11
|
+
// addTransitionType is only available in React experimental
|
|
12
|
+
const addTransitionType: ((type: string) => void) | undefined =
|
|
13
|
+
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
9
14
|
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
15
|
+
import { reconcileSegments } from "./segment-reconciler.js";
|
|
16
|
+
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
10
17
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
18
|
+
hasActiveIntercept as hasActiveInterceptSlots,
|
|
19
|
+
isInterceptSegment,
|
|
20
|
+
} from "./intercept-utils.js";
|
|
21
|
+
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
22
|
+
import { ServerRedirect } from "../errors.js";
|
|
23
|
+
import { debugLog } from "./logging.js";
|
|
24
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
25
|
+
import type { NavigationUpdate } from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** Build a scroll payload from the commit's scroll option */
|
|
28
|
+
function toScrollPayload(
|
|
29
|
+
scroll: boolean | undefined,
|
|
30
|
+
): NonNullable<NavigationUpdate["scroll"]> {
|
|
31
|
+
return { enabled: scroll !== false ? scroll : false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether to wrap an update in startViewTransition.
|
|
36
|
+
*
|
|
37
|
+
* Intercept-driven updates only mutate the parallel slot — the main outlet
|
|
38
|
+
* shows the same content — so transitions on the underlying main segments
|
|
39
|
+
* shouldn't fire (otherwise their elements get hoisted above the modal).
|
|
40
|
+
*/
|
|
41
|
+
function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
|
|
42
|
+
let hasIntercept = false;
|
|
43
|
+
let hasTransition = false;
|
|
44
|
+
for (const s of segments) {
|
|
45
|
+
if (isInterceptSegment(s)) hasIntercept = true;
|
|
46
|
+
else if (s.transition) hasTransition = true;
|
|
47
|
+
}
|
|
48
|
+
return !hasIntercept && hasTransition;
|
|
49
|
+
}
|
|
17
50
|
|
|
18
51
|
/**
|
|
19
52
|
* Configuration for creating a partial updater
|
|
@@ -26,8 +59,15 @@ export interface PartialUpdateConfig {
|
|
|
26
59
|
segments: ResolvedSegment[],
|
|
27
60
|
options?: RenderSegmentsOptions,
|
|
28
61
|
) => Promise<ReactNode> | ReactNode;
|
|
29
|
-
/** RSC version
|
|
30
|
-
|
|
62
|
+
/** RSC version getter — returns the current version (may change after HMR) */
|
|
63
|
+
getVersion?: () => string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Replace the active app-shell when a cross-app navigation is detected.
|
|
66
|
+
* Called before the full-update tree replacement renders, so the new
|
|
67
|
+
* payload's rootLayout, basename, and version are picked up. Theme,
|
|
68
|
+
* warmup, and prefetch TTL are not part of the shell — see AppShell.
|
|
69
|
+
*/
|
|
70
|
+
applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
|
|
31
71
|
}
|
|
32
72
|
|
|
33
73
|
/**
|
|
@@ -42,12 +82,30 @@ export interface CommitOverrides {
|
|
|
42
82
|
intercept?: boolean;
|
|
43
83
|
/** Source URL where intercept was triggered from */
|
|
44
84
|
interceptSourceUrl?: string;
|
|
85
|
+
/** Server-set location state to merge into history.pushState */
|
|
86
|
+
serverState?: Record<string, unknown>;
|
|
45
87
|
}
|
|
46
88
|
|
|
47
89
|
/**
|
|
48
|
-
*
|
|
49
|
-
* Transaction encapsulates all store mutations for atomic commit
|
|
90
|
+
* Discriminated update mode for partial updates.
|
|
50
91
|
*/
|
|
92
|
+
export type UpdateMode =
|
|
93
|
+
| {
|
|
94
|
+
type: "navigate";
|
|
95
|
+
/** Cached segments for the target URL. When provided, these are used to build
|
|
96
|
+
* the segment map instead of the current page's segments. This ensures consistency
|
|
97
|
+
* when we send cached segment IDs to the server - if the server returns empty diff,
|
|
98
|
+
* we use the same segments we told the server we have. */
|
|
99
|
+
targetCacheSegments?: ResolvedSegment[];
|
|
100
|
+
/** Cached handle data for the target URL. When server returns empty diff and we're
|
|
101
|
+
* rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
|
|
102
|
+
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
103
|
+
/** Source URL for intercept restore (popstate cache miss) */
|
|
104
|
+
interceptSourceUrl?: string;
|
|
105
|
+
}
|
|
106
|
+
| { type: "leave-intercept" }
|
|
107
|
+
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
108
|
+
| { type: "action"; interceptSourceUrl?: string };
|
|
51
109
|
|
|
52
110
|
/**
|
|
53
111
|
* Type for the fetchPartialUpdate function
|
|
@@ -57,24 +115,9 @@ export type PartialUpdater = (
|
|
|
57
115
|
segmentIds: string[] | undefined,
|
|
58
116
|
isRetry: boolean,
|
|
59
117
|
signal: AbortSignal | undefined,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
staleRevalidation?: boolean;
|
|
64
|
-
interceptSourceUrl?: string;
|
|
65
|
-
/** Cached segments for the target URL. When provided, these are used to build
|
|
66
|
-
* the segment map instead of the current page's segments. This ensures consistency
|
|
67
|
-
* when we send cached segment IDs to the server - if the server returns empty diff,
|
|
68
|
-
* we use the same segments we told the server we have. */
|
|
69
|
-
targetCacheSegments?: ResolvedSegment[];
|
|
70
|
-
/** Cached handle data for the target URL. When server returns empty diff and we're
|
|
71
|
-
* rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
|
|
72
|
-
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
73
|
-
/** When true, we're leaving an intercept state - don't use current segment IDs
|
|
74
|
-
* as fallback and force a fresh render from server */
|
|
75
|
-
leavingIntercept?: boolean;
|
|
76
|
-
},
|
|
77
|
-
) => Promise<Promise<void>>;
|
|
118
|
+
tx: BoundTransaction,
|
|
119
|
+
mode?: UpdateMode,
|
|
120
|
+
) => Promise<void>;
|
|
78
121
|
|
|
79
122
|
/**
|
|
80
123
|
* Create a partial updater for fetching and applying RSC partial updates
|
|
@@ -84,39 +127,30 @@ export type PartialUpdater = (
|
|
|
84
127
|
*
|
|
85
128
|
* @param config - Partial update configuration
|
|
86
129
|
* @returns fetchPartialUpdate function
|
|
87
|
-
*
|
|
88
|
-
* @example
|
|
89
|
-
* ```typescript
|
|
90
|
-
* const fetchPartialUpdate = createPartialUpdater({
|
|
91
|
-
* store,
|
|
92
|
-
* client,
|
|
93
|
-
* onUpdate: (update) => store.emit(update),
|
|
94
|
-
* renderSegments,
|
|
95
|
-
* });
|
|
96
|
-
*
|
|
97
|
-
* await fetchPartialUpdate('/new-page');
|
|
98
|
-
* ```
|
|
99
130
|
*/
|
|
100
131
|
export function createPartialUpdater(
|
|
101
132
|
config: PartialUpdateConfig,
|
|
102
133
|
): PartialUpdater {
|
|
103
|
-
const {
|
|
134
|
+
const {
|
|
135
|
+
store,
|
|
136
|
+
client,
|
|
137
|
+
onUpdate,
|
|
138
|
+
renderSegments,
|
|
139
|
+
getVersion = () => undefined,
|
|
140
|
+
applyAppShell,
|
|
141
|
+
} = config;
|
|
104
142
|
|
|
105
143
|
/**
|
|
106
|
-
*
|
|
144
|
+
* Get current page's cached segments as an array
|
|
107
145
|
*/
|
|
108
|
-
function
|
|
146
|
+
function getCurrentCachedSegments(): ResolvedSegment[] {
|
|
109
147
|
const currentKey = store.getHistoryKey();
|
|
110
148
|
const cached = store.getCachedSegments(currentKey);
|
|
111
|
-
|
|
112
|
-
const map = new Map<string, ResolvedSegment>();
|
|
113
|
-
cachedSegments.forEach((s) => map.set(s.id, s));
|
|
114
|
-
return map;
|
|
149
|
+
return cached?.segments || [];
|
|
115
150
|
}
|
|
116
151
|
|
|
117
152
|
/**
|
|
118
153
|
* Fetch partial update and trigger UI update
|
|
119
|
-
* Returns a promise that resolves when the RSC stream is fully consumed
|
|
120
154
|
*
|
|
121
155
|
* @param tx - Transaction for committing segment state (required)
|
|
122
156
|
* @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
|
|
@@ -127,251 +161,272 @@ export function createPartialUpdater(
|
|
|
127
161
|
isRetry: boolean,
|
|
128
162
|
signal: AbortSignal | undefined,
|
|
129
163
|
tx: BoundTransaction,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
staleRevalidation?: boolean;
|
|
133
|
-
interceptSourceUrl?: string;
|
|
134
|
-
targetCacheSegments?: ResolvedSegment[];
|
|
135
|
-
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
136
|
-
leavingIntercept?: boolean;
|
|
137
|
-
},
|
|
138
|
-
): Promise<Promise<void>> {
|
|
139
|
-
const {
|
|
140
|
-
isAction = false,
|
|
141
|
-
staleRevalidation = false,
|
|
142
|
-
interceptSourceUrl,
|
|
143
|
-
targetCacheSegments,
|
|
144
|
-
targetCacheHandleData,
|
|
145
|
-
leavingIntercept = false,
|
|
146
|
-
} = options || {};
|
|
164
|
+
mode: UpdateMode = { type: "navigate" },
|
|
165
|
+
): Promise<void> {
|
|
147
166
|
const segmentState = store.getSegmentState();
|
|
148
167
|
const url = targetUrl || window.location.href;
|
|
149
168
|
|
|
150
169
|
// Capture history key at start for stale revalidation consistency check
|
|
151
170
|
const historyKeyAtStart = store.getHistoryKey();
|
|
152
171
|
|
|
153
|
-
//
|
|
154
|
-
|
|
172
|
+
// Derive interceptSourceUrl from modes that carry it
|
|
173
|
+
const interceptSourceUrl =
|
|
174
|
+
mode.type === "stale-revalidation" ||
|
|
175
|
+
mode.type === "action" ||
|
|
176
|
+
mode.type === "navigate"
|
|
177
|
+
? mode.interceptSourceUrl
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
180
|
+
// When leaving intercept, filter out intercept-specific segments
|
|
155
181
|
let segments: string[];
|
|
156
|
-
if (
|
|
157
|
-
// When leaving intercept, only send segments that aren't intercept-specific
|
|
158
|
-
// The server will return the non-intercept version of the route
|
|
182
|
+
if (mode.type === "leave-intercept") {
|
|
159
183
|
const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
const currentCached = getCurrentCachedSegments();
|
|
185
|
+
const interceptIds = new Set(
|
|
186
|
+
currentCached
|
|
187
|
+
.filter((s) => s.namespace?.startsWith("intercept:"))
|
|
188
|
+
.map((s) => s.id),
|
|
189
|
+
);
|
|
190
|
+
segments = currentSegments.filter((id) => !interceptIds.has(id));
|
|
191
|
+
debugLog(
|
|
165
192
|
`[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
|
|
166
193
|
);
|
|
167
194
|
} else {
|
|
168
195
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
169
196
|
}
|
|
170
197
|
|
|
171
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
172
|
-
//
|
|
198
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
199
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
200
|
+
// creation, which on popstate is already the destination URL and would
|
|
201
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
202
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
203
|
+
// correct "from" for the server's diff computation.
|
|
173
204
|
const previousUrl =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
mode.type === "leave-intercept"
|
|
206
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
207
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
208
|
+
|
|
209
|
+
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
210
|
+
debugLog(`[Browser] From: ${previousUrl}`);
|
|
211
|
+
debugLog(`[Browser] To: ${url}`);
|
|
212
|
+
debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
|
|
180
213
|
if (interceptSourceUrl) {
|
|
181
|
-
|
|
214
|
+
debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
|
|
182
215
|
}
|
|
183
216
|
|
|
184
|
-
//
|
|
185
|
-
// When
|
|
186
|
-
// to ensure consistency - we use the same segments we told the server we have.
|
|
217
|
+
// Get cached segments for merging with server diff.
|
|
218
|
+
// When navigating with targetCacheSegments, use those for consistency.
|
|
187
219
|
// Otherwise fall back to current page's segments (for same-route revalidation).
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
220
|
+
const targetCache =
|
|
221
|
+
mode.type === "navigate" ? mode.targetCacheSegments : undefined;
|
|
222
|
+
const cachedSegs =
|
|
223
|
+
targetCache && targetCache.length > 0
|
|
224
|
+
? targetCache
|
|
225
|
+
: getCurrentCachedSegments();
|
|
226
|
+
const cachedSegsSource =
|
|
227
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
228
|
+
debugLog(
|
|
229
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
230
|
+
);
|
|
231
|
+
|
|
198
232
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
233
|
+
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
234
|
+
fetchResult = await client.fetchPartial({
|
|
235
|
+
targetUrl: url,
|
|
236
|
+
segmentIds: segments,
|
|
237
|
+
previousUrl,
|
|
238
|
+
// Mark stale when explicitly requested OR when no segments are sent
|
|
239
|
+
// (action redirect sends empty segments for a fresh render).
|
|
240
|
+
staleRevalidation:
|
|
241
|
+
mode.type === "stale-revalidation" || segments.length === 0,
|
|
242
|
+
version: getVersion(),
|
|
243
|
+
routerId: store.getRouterId?.(),
|
|
244
|
+
});
|
|
245
|
+
// Mark navigation as streaming (response received, now parsing RSC).
|
|
246
|
+
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
247
|
+
// allowing useLinkStatus to show per-link pending indicators.
|
|
248
|
+
const streamingToken = tx.startStreaming();
|
|
249
|
+
const { payload, streamComplete: rawStreamComplete } = fetchResult;
|
|
250
|
+
debugLog("payload.metadata", payload.metadata);
|
|
208
251
|
|
|
209
252
|
const streamComplete = rawStreamComplete.then(() => {
|
|
210
253
|
streamingToken.end();
|
|
211
254
|
});
|
|
212
255
|
|
|
256
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
257
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
258
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
259
|
+
// against stale segments from the previous app, and replace the app
|
|
260
|
+
// shell (rootLayout, basename, version) so the target app's document
|
|
261
|
+
// and router config take effect instead of remaining captured from the
|
|
262
|
+
// initial load. Theme, warmup, and prefetch TTL are intentionally
|
|
263
|
+
// document-lifetime (see AppShell doc); a new document navigation
|
|
264
|
+
// applies them.
|
|
265
|
+
if (payload.metadata?.routerId) {
|
|
266
|
+
const prevRouterId = store.getRouterId?.();
|
|
267
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
268
|
+
debugLog(
|
|
269
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
270
|
+
);
|
|
271
|
+
payload.metadata.isPartial = false;
|
|
272
|
+
applyAppShell?.({
|
|
273
|
+
routerId: payload.metadata.routerId,
|
|
274
|
+
rootLayout: payload.metadata.rootLayout,
|
|
275
|
+
basename: payload.metadata.basename,
|
|
276
|
+
version: payload.metadata.version,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Handle server-side redirect with state
|
|
283
|
+
if (payload.metadata?.redirect) {
|
|
284
|
+
if (signal?.aborted) {
|
|
285
|
+
debugLog("[Browser] Ignoring stale redirect (aborted)");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const redirectUrl = validateRedirectOrigin(
|
|
289
|
+
payload.metadata.redirect.url,
|
|
290
|
+
window.location.origin,
|
|
291
|
+
);
|
|
292
|
+
if (!redirectUrl) {
|
|
293
|
+
debugLog("[Browser] Ignoring blocked redirect payload");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const serverState = payload.metadata.locationState;
|
|
297
|
+
throw new ServerRedirect(redirectUrl, serverState);
|
|
298
|
+
}
|
|
299
|
+
|
|
213
300
|
if (payload.metadata?.isPartial) {
|
|
214
301
|
const { segments: newSegments, matched, diff } = payload.metadata;
|
|
215
302
|
|
|
216
303
|
// Check if this navigation is stale (a newer one started)
|
|
217
304
|
if (signal?.aborted) {
|
|
218
|
-
|
|
219
|
-
return
|
|
305
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
306
|
+
return;
|
|
220
307
|
}
|
|
221
308
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Create lookup for new segments from server
|
|
226
|
-
const newSegmentMap = new Map<string, ResolvedSegment>();
|
|
227
|
-
(newSegments || []).forEach((s: ResolvedSegment) =>
|
|
228
|
-
newSegmentMap.set(s.id, s),
|
|
229
|
-
);
|
|
309
|
+
debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
|
|
310
|
+
debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
|
|
230
311
|
|
|
231
312
|
// If diff is empty, nothing changed on server side.
|
|
232
|
-
// However, if we're navigating with targetCacheSegments (to a different route),
|
|
233
|
-
// we still need to render those segments since the UI is showing the old route.
|
|
234
313
|
if (!diff || diff.length === 0) {
|
|
235
314
|
const matchedIds = matched || [];
|
|
315
|
+
const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
|
|
236
316
|
const existingSegments = matchedIds
|
|
237
|
-
.map((id: string) =>
|
|
317
|
+
.map((id: string) => cacheMap.get(id))
|
|
238
318
|
.filter(Boolean) as ResolvedSegment[];
|
|
239
319
|
|
|
240
320
|
// When navigating with cached segments to a different route, render them.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
`[Browser] No diff but navigating with cached segments - rendering target route`,
|
|
321
|
+
if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
|
|
322
|
+
debugLog(
|
|
323
|
+
"[Browser] No diff but navigating with cached segments - rendering target route",
|
|
245
324
|
);
|
|
246
325
|
|
|
247
326
|
const newTree = await renderSegments(existingSegments, {
|
|
248
327
|
forceAwait: true,
|
|
249
328
|
});
|
|
250
329
|
|
|
251
|
-
tx.commit(
|
|
330
|
+
const { scroll: commitScroll } = tx.commit(
|
|
331
|
+
matchedIds,
|
|
332
|
+
existingSegments,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// tx.commit() cached the source page's handleData because
|
|
336
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
337
|
+
// correct cached handleData to prevent cache corruption on
|
|
338
|
+
// subsequent navigations to this same URL.
|
|
339
|
+
if (mode.targetCacheHandleData) {
|
|
340
|
+
store.updateCacheHandleData(
|
|
341
|
+
store.getHistoryKey(),
|
|
342
|
+
mode.targetCacheHandleData,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
252
345
|
|
|
253
346
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
254
347
|
// breadcrumbs and other handle data from cache.
|
|
255
|
-
//
|
|
348
|
+
// Remove `handles` from metadata to prevent NavigationProvider from
|
|
256
349
|
// processing an empty handles stream, which would clear the cached breadcrumbs.
|
|
257
|
-
// When rendering from cache with empty diff, we want to use cachedHandleData instead.
|
|
258
350
|
const { handles: _unusedHandles, ...metadataWithoutHandles } =
|
|
259
351
|
payload.metadata!;
|
|
260
|
-
|
|
352
|
+
const cachedUpdate = {
|
|
261
353
|
root: newTree,
|
|
262
354
|
metadata: {
|
|
263
355
|
...metadataWithoutHandles,
|
|
264
|
-
cachedHandleData: targetCacheHandleData,
|
|
356
|
+
cachedHandleData: mode.targetCacheHandleData,
|
|
265
357
|
},
|
|
266
|
-
|
|
358
|
+
scroll: toScrollPayload(commitScroll),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (shouldStartViewTransition(existingSegments)) {
|
|
362
|
+
startTransition(() => {
|
|
363
|
+
if (addTransitionType) {
|
|
364
|
+
addTransitionType("navigation");
|
|
365
|
+
}
|
|
366
|
+
onUpdate(cachedUpdate);
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
onUpdate(cachedUpdate);
|
|
370
|
+
}
|
|
267
371
|
|
|
268
|
-
|
|
269
|
-
return
|
|
372
|
+
debugLog("[Browser] Navigation complete (rendered from cache)");
|
|
373
|
+
return;
|
|
270
374
|
}
|
|
271
375
|
|
|
272
376
|
// When leaving intercept, force re-render even with empty diff
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
console.log(
|
|
277
|
-
`[Browser] Leaving intercept - forcing re-render to remove modal`,
|
|
377
|
+
if (mode.type === "leave-intercept") {
|
|
378
|
+
debugLog(
|
|
379
|
+
"[Browser] Leaving intercept - forcing re-render to remove modal",
|
|
278
380
|
);
|
|
279
381
|
|
|
280
382
|
const newTree = await renderSegments(existingSegments, {
|
|
281
383
|
forceAwait: true,
|
|
282
384
|
});
|
|
283
385
|
|
|
284
|
-
tx.commit(
|
|
386
|
+
const { scroll: leaveScroll } = tx.commit(
|
|
387
|
+
matchedIds,
|
|
388
|
+
existingSegments,
|
|
389
|
+
);
|
|
285
390
|
|
|
286
391
|
onUpdate({
|
|
287
392
|
root: newTree,
|
|
288
393
|
metadata: payload.metadata,
|
|
394
|
+
scroll: toScrollPayload(leaveScroll),
|
|
289
395
|
});
|
|
290
396
|
|
|
291
|
-
|
|
292
|
-
return
|
|
397
|
+
debugLog("[Browser] Navigation complete (left intercept)");
|
|
398
|
+
return;
|
|
293
399
|
}
|
|
294
400
|
|
|
295
401
|
// Same route revalidation with no changes - skip UI update
|
|
296
|
-
|
|
297
|
-
|
|
402
|
+
debugLog(
|
|
403
|
+
"[Browser] No changes - all revalidations returned false, keeping existing UI",
|
|
298
404
|
);
|
|
299
405
|
tx.commit(matchedIds, existingSegments);
|
|
300
|
-
|
|
301
|
-
return
|
|
406
|
+
debugLog("[Browser] Navigation complete (no re-render)");
|
|
407
|
+
return;
|
|
302
408
|
}
|
|
303
409
|
|
|
304
|
-
//
|
|
305
|
-
// - New/changed segments from server response (diff)
|
|
306
|
-
// - Unchanged segments from current page's cache
|
|
410
|
+
// Reconcile server segments with cached segments (single source of truth)
|
|
307
411
|
const matchedIds = matched || [];
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// First check server response (new/updated segments)
|
|
322
|
-
const fromServer = newSegmentMap.get(id);
|
|
323
|
-
if (fromServer) {
|
|
324
|
-
// For partial revalidation (stale or action), merge server's new loader data
|
|
325
|
-
// with cached loader data when server returns fewer loaders than cached
|
|
326
|
-
const fromCache = currentSegmentMap.get(id);
|
|
327
|
-
// Dev-mode assertion: warn if tree structure would change
|
|
328
|
-
if (fromCache) {
|
|
329
|
-
assertSegmentStructure(fromCache, fromServer, "partial-update");
|
|
330
|
-
}
|
|
331
|
-
if (
|
|
332
|
-
(staleRevalidation || isAction) &&
|
|
333
|
-
needsLoaderMerge(fromServer, fromCache)
|
|
334
|
-
) {
|
|
335
|
-
return mergeSegmentLoaders(fromServer, fromCache);
|
|
336
|
-
}
|
|
337
|
-
// When server returns component: null for a layout segment, it means
|
|
338
|
-
// "this segment doesn't need re-rendering" - preserve the cached component
|
|
339
|
-
// to maintain the outlet chain and prevent React tree changes
|
|
340
|
-
if (
|
|
341
|
-
fromServer.component === null &&
|
|
342
|
-
fromServer.type === "layout" &&
|
|
343
|
-
fromCache?.component != null
|
|
344
|
-
) {
|
|
345
|
-
console.log(
|
|
346
|
-
`[Browser] Preserving cached component for layout ${id} (server returned null)`,
|
|
347
|
-
);
|
|
348
|
-
return { ...fromServer, component: fromCache.component };
|
|
349
|
-
}
|
|
350
|
-
return fromServer;
|
|
351
|
-
}
|
|
352
|
-
// Fall back to current page's cached segments
|
|
353
|
-
const fromCache = currentSegmentMap.get(id);
|
|
354
|
-
if (!fromCache) {
|
|
355
|
-
console.warn(`[Browser] Missing segment: ${id}`);
|
|
356
|
-
return fromCache;
|
|
357
|
-
}
|
|
358
|
-
// Clear loading for cached segments to prevent suspense - server decided
|
|
359
|
-
// this segment doesn't need re-rendering, so show content as-is
|
|
360
|
-
if (fromCache.loading !== undefined) {
|
|
361
|
-
return { ...fromCache, loading: undefined };
|
|
362
|
-
}
|
|
363
|
-
return fromCache;
|
|
364
|
-
})
|
|
365
|
-
.filter(Boolean) as ResolvedSegment[];
|
|
366
|
-
|
|
367
|
-
// Insert diff segments not in matchedIds (e.g., loader segments from consolidation fetch)
|
|
368
|
-
insertMissingDiffSegments(allSegments, diff, matchedIdSet, newSegmentMap);
|
|
412
|
+
const actor: ReconcileActor =
|
|
413
|
+
mode.type === "stale-revalidation" || mode.type === "action"
|
|
414
|
+
? "stale-revalidation"
|
|
415
|
+
: "navigation";
|
|
416
|
+
|
|
417
|
+
const reconciled = reconcileSegments({
|
|
418
|
+
actor,
|
|
419
|
+
matched: matchedIds,
|
|
420
|
+
diff: diff || [],
|
|
421
|
+
serverSegments: newSegments || [],
|
|
422
|
+
cachedSegments: cachedSegs,
|
|
423
|
+
insertMissingDiff: true,
|
|
424
|
+
});
|
|
369
425
|
|
|
370
426
|
// HMR RESILIENCE: Check if we're missing any matched segments
|
|
371
|
-
|
|
372
|
-
const allSegmentIdSet = new Set(allSegments.map((s) => s.id));
|
|
427
|
+
const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
|
|
373
428
|
const missingIds = matchedIds.filter(
|
|
374
|
-
(id: string) => !
|
|
429
|
+
(id: string) => !reconciledIdSet.has(id),
|
|
375
430
|
);
|
|
376
431
|
|
|
377
432
|
if (missingIds.length > 0) {
|
|
@@ -384,52 +439,39 @@ export function createPartialUpdater(
|
|
|
384
439
|
);
|
|
385
440
|
}
|
|
386
441
|
if (signal?.aborted) {
|
|
387
|
-
|
|
388
|
-
|
|
442
|
+
debugLog(
|
|
443
|
+
"[Browser] Ignoring stale navigation (aborted during HMR retry)",
|
|
389
444
|
);
|
|
390
|
-
return
|
|
445
|
+
return;
|
|
391
446
|
}
|
|
392
|
-
if (
|
|
393
|
-
return
|
|
447
|
+
if (mode.type === "action") {
|
|
448
|
+
return;
|
|
394
449
|
}
|
|
395
450
|
console.warn(
|
|
396
451
|
`[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
|
|
397
452
|
);
|
|
398
453
|
|
|
399
454
|
// Refetch with empty segments = server sends everything
|
|
400
|
-
return fetchPartialUpdate(url, [], true, signal, tx,
|
|
455
|
+
return fetchPartialUpdate(url, [], true, signal, tx, mode);
|
|
401
456
|
}
|
|
402
457
|
|
|
403
|
-
// INTERCEPT HANDLING: Separate intercept segments for explicit injection
|
|
404
|
-
// Intercept segments have namespace starting with "intercept:" or ID containing .@
|
|
405
|
-
// This makes the flow clearer and easier to debug
|
|
406
|
-
const isInterceptSegment = (s: ResolvedSegment) =>
|
|
407
|
-
s.namespace?.startsWith("intercept:") ||
|
|
408
|
-
(s.type === "parallel" && s.id.includes(".@"));
|
|
409
|
-
|
|
410
|
-
const interceptSegments = allSegments.filter(isInterceptSegment);
|
|
411
|
-
const mainSegments = allSegments.filter((s) => !isInterceptSegment(s));
|
|
412
|
-
|
|
413
458
|
if (signal?.aborted) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
);
|
|
417
|
-
return streamComplete;
|
|
459
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before render)");
|
|
460
|
+
return;
|
|
418
461
|
}
|
|
419
462
|
|
|
420
463
|
// Rebuild tree on client (await for loader data resolution)
|
|
421
|
-
// Race against abort signal to allow cancellation during loader awaiting
|
|
422
|
-
// Pass intercept segments separately for explicit handling
|
|
423
|
-
// For stale revalidation, use forceAwait to ensure no loading fallbacks
|
|
424
464
|
const renderOptions = {
|
|
425
|
-
isAction,
|
|
426
|
-
forceAwait:
|
|
465
|
+
isAction: mode.type === "action",
|
|
466
|
+
forceAwait: mode.type === "stale-revalidation",
|
|
427
467
|
interceptSegments:
|
|
428
|
-
interceptSegments.length > 0
|
|
468
|
+
reconciled.interceptSegments.length > 0
|
|
469
|
+
? reconciled.interceptSegments
|
|
470
|
+
: undefined,
|
|
429
471
|
};
|
|
430
472
|
const newTree = await (signal
|
|
431
473
|
? Promise.race([
|
|
432
|
-
renderSegments(mainSegments, renderOptions),
|
|
474
|
+
renderSegments(reconciled.mainSegments, renderOptions),
|
|
433
475
|
new Promise<never>((_, reject) => {
|
|
434
476
|
if (signal.aborted) {
|
|
435
477
|
reject(new DOMException("Navigation aborted", "AbortError"));
|
|
@@ -439,158 +481,177 @@ export function createPartialUpdater(
|
|
|
439
481
|
});
|
|
440
482
|
}),
|
|
441
483
|
])
|
|
442
|
-
: renderSegments(mainSegments, renderOptions));
|
|
484
|
+
: renderSegments(reconciled.mainSegments, renderOptions));
|
|
443
485
|
|
|
444
486
|
// Final abort check before committing - another navigation may have started
|
|
445
487
|
if (signal?.aborted) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
);
|
|
449
|
-
return streamComplete;
|
|
488
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
|
|
489
|
+
return;
|
|
450
490
|
}
|
|
451
491
|
|
|
452
492
|
// Check if this is an intercept response (any slot is active)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
: false;
|
|
457
|
-
|
|
458
|
-
// BUG FIX: When navigating with cached target segments but receiving an intercept response,
|
|
459
|
-
// the background segments should come from the SOURCE page (where we navigated from),
|
|
460
|
-
// not the TARGET cache. This happens when:
|
|
461
|
-
// 1. User visits /product/xxx (detail page) - cached under key "/product/xxx"
|
|
462
|
-
// 2. User navigates back to /
|
|
463
|
-
// 3. User clicks product link → cache hit for "/product/xxx" (detail page)
|
|
464
|
-
// 4. But server returns intercept response (modal with index background)
|
|
465
|
-
// 5. Without this fix: background uses detail page segments (wrong!)
|
|
466
|
-
// 6. With this fix: rebuild currentSegmentMap from source page
|
|
467
|
-
if (hasActiveIntercept && targetCacheSegments) {
|
|
468
|
-
console.log(
|
|
469
|
-
`[Browser] Intercept response with target cache - rebuilding segment map from source page`,
|
|
470
|
-
);
|
|
471
|
-
currentSegmentMap = getCurrentSegmentMap();
|
|
472
|
-
}
|
|
493
|
+
const isInterceptResponse = hasActiveInterceptSlots(
|
|
494
|
+
payload.metadata?.slots,
|
|
495
|
+
);
|
|
473
496
|
|
|
474
|
-
// Track intercept context
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
497
|
+
// Track intercept context (only on navigation, not actions or stale revalidation)
|
|
498
|
+
// Use the authoritative source from mode/history state when restoring an
|
|
499
|
+
// intercept via popstate cache miss; fall back to the current URL for fresh
|
|
500
|
+
// intercept navigations.
|
|
501
|
+
const effectiveInterceptSource =
|
|
502
|
+
interceptSourceUrl || segmentState.currentUrl;
|
|
503
|
+
if (mode.type !== "action" && mode.type !== "stale-revalidation") {
|
|
504
|
+
if (isInterceptResponse) {
|
|
505
|
+
store.setInterceptSourceUrl(effectiveInterceptSource);
|
|
479
506
|
} else {
|
|
480
|
-
// Clear intercept context when navigating to a non-intercept route
|
|
481
507
|
store.setInterceptSourceUrl(null);
|
|
482
508
|
}
|
|
483
509
|
}
|
|
484
510
|
|
|
485
|
-
// Commit navigation -
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
511
|
+
// Commit navigation - use server's matched as the authoritative segment ID list.
|
|
512
|
+
// reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
|
|
513
|
+
// but the server's matched always includes all expected segment IDs.
|
|
514
|
+
const allSegmentIds = matchedIds;
|
|
515
|
+
const serverLocationState = payload.metadata?.locationState;
|
|
516
|
+
const overrides: CommitOverrides | undefined = isInterceptResponse
|
|
517
|
+
? {
|
|
518
|
+
scroll: false,
|
|
519
|
+
intercept: true,
|
|
520
|
+
interceptSourceUrl: effectiveInterceptSource,
|
|
521
|
+
...(serverLocationState && { serverState: serverLocationState }),
|
|
522
|
+
}
|
|
523
|
+
: serverLocationState
|
|
524
|
+
? { serverState: serverLocationState }
|
|
525
|
+
: undefined;
|
|
526
|
+
const { scroll: navScroll } = tx.commit(
|
|
491
527
|
allSegmentIds,
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
? {
|
|
495
|
-
scroll: false,
|
|
496
|
-
intercept: true,
|
|
497
|
-
interceptSourceUrl: segmentState.currentUrl,
|
|
498
|
-
}
|
|
499
|
-
: undefined,
|
|
528
|
+
reconciled.segments,
|
|
529
|
+
overrides,
|
|
500
530
|
);
|
|
501
531
|
|
|
502
532
|
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
503
|
-
|
|
504
|
-
if (staleRevalidation) {
|
|
533
|
+
if (mode.type === "stale-revalidation") {
|
|
505
534
|
const historyKeyNow = store.getHistoryKey();
|
|
506
535
|
if (historyKeyNow !== historyKeyAtStart) {
|
|
507
|
-
|
|
536
|
+
debugLog(
|
|
508
537
|
`[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
|
|
509
538
|
);
|
|
510
|
-
return
|
|
539
|
+
return;
|
|
511
540
|
}
|
|
512
541
|
}
|
|
513
542
|
|
|
514
|
-
|
|
543
|
+
debugLog("[partial-update] updating document");
|
|
515
544
|
|
|
516
|
-
// Emit update to trigger React render
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
545
|
+
// Emit update to trigger React render.
|
|
546
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
547
|
+
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
548
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
549
|
+
|
|
550
|
+
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
520
551
|
startTransition(() => {
|
|
552
|
+
if (hasTransition && addTransitionType) {
|
|
553
|
+
addTransitionType("action");
|
|
554
|
+
}
|
|
521
555
|
onUpdate({
|
|
522
556
|
root: newTree,
|
|
523
557
|
metadata: payload.metadata!,
|
|
558
|
+
scroll: scrollPayload,
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
} else if (hasTransition) {
|
|
562
|
+
startTransition(() => {
|
|
563
|
+
if (addTransitionType) {
|
|
564
|
+
addTransitionType("navigation");
|
|
565
|
+
}
|
|
566
|
+
onUpdate({
|
|
567
|
+
root: newTree,
|
|
568
|
+
metadata: payload.metadata!,
|
|
569
|
+
scroll: scrollPayload,
|
|
524
570
|
});
|
|
525
571
|
});
|
|
526
572
|
} else {
|
|
527
573
|
onUpdate({
|
|
528
574
|
root: newTree,
|
|
529
575
|
metadata: payload.metadata!,
|
|
576
|
+
scroll: scrollPayload,
|
|
530
577
|
});
|
|
531
578
|
}
|
|
532
579
|
|
|
533
|
-
|
|
534
|
-
return
|
|
580
|
+
debugLog("[Browser] Navigation complete");
|
|
581
|
+
return;
|
|
535
582
|
} else {
|
|
536
583
|
// Full update (fallback)
|
|
537
|
-
// Use client-side renderSegments instead of payload.root to ensure
|
|
538
|
-
// consistent component references with action revalidation.
|
|
539
|
-
// Server-rendered RSC tree has different component references than
|
|
540
|
-
// client-created tree, which causes React to remount LoaderBoundary
|
|
541
|
-
// when actions trigger revalidation.
|
|
542
584
|
console.warn(`[Browser] Full update (fallback)`);
|
|
543
585
|
|
|
544
586
|
const segments = payload.metadata?.segments || [];
|
|
545
587
|
|
|
546
|
-
// Check if this navigation is stale (a newer one started)
|
|
547
588
|
if (signal?.aborted) {
|
|
548
|
-
|
|
549
|
-
return
|
|
589
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
590
|
+
return;
|
|
550
591
|
}
|
|
551
592
|
|
|
552
593
|
const segmentIds = segments.map((s: ResolvedSegment) => s.id);
|
|
553
594
|
|
|
554
|
-
// Render on client for consistent component references
|
|
555
595
|
const newTree = await renderSegments(segments);
|
|
556
596
|
|
|
557
|
-
// Final abort check before committing - another navigation may have started
|
|
558
597
|
if (signal?.aborted) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
);
|
|
562
|
-
return streamComplete;
|
|
598
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
|
|
599
|
+
return;
|
|
563
600
|
}
|
|
564
601
|
|
|
565
|
-
|
|
566
|
-
|
|
602
|
+
const fullUpdateServerState = payload.metadata?.locationState;
|
|
603
|
+
const { scroll: fullScroll } = fullUpdateServerState
|
|
604
|
+
? tx.commit(segmentIds, segments, {
|
|
605
|
+
serverState: fullUpdateServerState,
|
|
606
|
+
})
|
|
607
|
+
: tx.commit(segmentIds, segments);
|
|
608
|
+
|
|
609
|
+
const fullHasTransition = shouldStartViewTransition(segments);
|
|
610
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
567
611
|
|
|
568
|
-
|
|
569
|
-
// For stale revalidation: wait for stream to complete, then update
|
|
570
|
-
// For actions: wrap in startTransition to avoid UI flickering
|
|
571
|
-
if (staleRevalidation) {
|
|
612
|
+
if (mode.type === "stale-revalidation") {
|
|
572
613
|
await rawStreamComplete;
|
|
573
614
|
startTransition(() => {
|
|
615
|
+
if (fullHasTransition && addTransitionType) {
|
|
616
|
+
addTransitionType("action");
|
|
617
|
+
}
|
|
574
618
|
onUpdate({
|
|
575
619
|
root: newTree,
|
|
576
620
|
metadata: payload.metadata!,
|
|
621
|
+
scroll: fullScrollPayload,
|
|
577
622
|
});
|
|
578
623
|
});
|
|
579
|
-
} else if (
|
|
624
|
+
} else if (mode.type === "action") {
|
|
580
625
|
startTransition(async () => {
|
|
626
|
+
if (fullHasTransition && addTransitionType) {
|
|
627
|
+
addTransitionType("action");
|
|
628
|
+
}
|
|
629
|
+
onUpdate({
|
|
630
|
+
root: newTree,
|
|
631
|
+
metadata: payload.metadata!,
|
|
632
|
+
scroll: fullScrollPayload,
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
} else if (fullHasTransition) {
|
|
636
|
+
startTransition(() => {
|
|
637
|
+
if (addTransitionType) {
|
|
638
|
+
addTransitionType("navigation");
|
|
639
|
+
}
|
|
581
640
|
onUpdate({
|
|
582
641
|
root: newTree,
|
|
583
642
|
metadata: payload.metadata!,
|
|
643
|
+
scroll: fullScrollPayload,
|
|
584
644
|
});
|
|
585
645
|
});
|
|
586
646
|
} else {
|
|
587
647
|
onUpdate({
|
|
588
648
|
root: newTree,
|
|
589
649
|
metadata: payload.metadata!,
|
|
650
|
+
scroll: fullScrollPayload,
|
|
590
651
|
});
|
|
591
652
|
}
|
|
592
653
|
|
|
593
|
-
return
|
|
654
|
+
return;
|
|
594
655
|
}
|
|
595
656
|
}
|
|
596
657
|
|