@rangojs/router 0.0.0-experimental.32 → 0.0.0-experimental.3232cd17
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 +4 -0
- package/README.md +198 -44
- package/dist/bin/rango.js +287 -105
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +3248 -1117
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +73 -21
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +107 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +245 -21
- package/skills/caching/SKILL.md +302 -6
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +270 -30
- package/skills/host-router/SKILL.md +82 -22
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +49 -5
- package/skills/layout/SKILL.md +35 -9
- package/skills/links/SKILL.md +249 -17
- package/skills/loader/SKILL.md +294 -30
- package/skills/middleware/SKILL.md +52 -13
- package/skills/migrate-nextjs/SKILL.md +584 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +203 -7
- package/skills/prerender/SKILL.md +123 -100
- package/skills/rango/SKILL.md +250 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +97 -5
- package/skills/router-setup/SKILL.md +90 -5
- package/skills/server-actions/SKILL.md +775 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +124 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +92 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +121 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/typesafety/SKILL.md +329 -27
- package/skills/use-cache/SKILL.md +36 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/__internal.ts +67 -40
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/action-fence.ts +47 -0
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/event-controller.ts +86 -147
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +148 -19
- package/src/browser/navigation-client.ts +187 -67
- package/src/browser/navigation-store-handle.ts +38 -0
- package/src/browser/navigation-store.ts +76 -67
- package/src/browser/navigation-transaction.ts +18 -66
- package/src/browser/partial-update.ts +123 -94
- package/src/browser/prefetch/cache.ts +214 -36
- package/src/browser/prefetch/fetch.ts +260 -38
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +126 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +158 -76
- package/src/browser/react/Link.tsx +93 -11
- package/src/browser/react/NavigationProvider.tsx +115 -34
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/filter-segment-order.ts +49 -7
- package/src/browser/react/index.ts +0 -48
- package/src/browser/react/location-state-shared.ts +166 -8
- package/src/browser/react/location-state.ts +39 -14
- package/src/browser/react/use-action.ts +6 -15
- package/src/browser/react/use-handle.ts +23 -69
- package/src/browser/react/use-link-status.ts +0 -4
- package/src/browser/react/use-navigation.ts +22 -5
- package/src/browser/react/use-params.ts +20 -10
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +46 -11
- package/src/browser/react/use-search-params.ts +0 -5
- package/src/browser/react/use-segments.ts +11 -21
- package/src/browser/response-adapter.ts +52 -1
- package/src/browser/rsc-router.tsx +215 -76
- package/src/browser/scroll-restoration.ts +46 -39
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +176 -50
- package/src/browser/types.ts +95 -11
- package/src/browser/validate-redirect-origin.ts +43 -16
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +65 -40
- package/src/build/generate-route-types.ts +5 -0
- package/src/build/index.ts +8 -2
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +137 -32
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +9 -2
- package/src/build/route-types/param-extraction.ts +6 -3
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +278 -96
- package/src/build/route-types/scan-filter.ts +9 -2
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +68 -28
- package/src/cache/cache-runtime.ts +149 -43
- package/src/cache/cache-scope.ts +148 -81
- package/src/cache/cache-tag.ts +98 -0
- package/src/cache/cf/cf-cache-store.ts +2550 -93
- package/src/cache/cf/index.ts +11 -17
- package/src/cache/document-cache.ts +78 -27
- package/src/cache/handle-snapshot.ts +63 -0
- package/src/cache/index.ts +23 -20
- package/src/cache/memory-segment-store.ts +136 -37
- package/src/cache/profile-registry.ts +6 -30
- package/src/cache/read-through-swr.ts +41 -11
- package/src/cache/segment-codec.ts +0 -16
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/taint.ts +55 -0
- package/src/cache/types.ts +33 -100
- package/src/cache/vercel/index.ts +11 -0
- package/src/cache/vercel/vercel-cache-store.ts +799 -0
- package/src/client.rsc.tsx +6 -21
- package/src/client.tsx +108 -290
- package/src/component-utils.ts +19 -0
- package/src/context-var.ts +84 -2
- package/src/debug.ts +2 -2
- package/src/decode-loader-results.ts +36 -0
- package/src/defer.ts +196 -0
- package/src/deps/ssr.ts +0 -1
- package/src/errors.ts +30 -4
- package/src/handle.ts +70 -22
- package/src/handles/MetaTags.tsx +0 -14
- package/src/handles/breadcrumbs.ts +16 -5
- package/src/handles/meta.ts +0 -39
- package/src/host/cookie-handler.ts +0 -36
- package/src/host/errors.ts +0 -24
- package/src/host/index.ts +8 -2
- package/src/host/pattern-matcher.ts +7 -50
- package/src/host/router.ts +107 -99
- package/src/host/testing.ts +40 -27
- package/src/host/types.ts +37 -4
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +137 -22
- package/src/index.rsc.ts +52 -26
- package/src/index.ts +100 -38
- package/src/internal-debug.ts +2 -4
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +20 -13
- package/src/loader.ts +12 -11
- package/src/missing-id-error.ts +68 -0
- package/src/network-error-thrower.tsx +1 -6
- package/src/outlet-context.ts +1 -1
- package/src/outlet-provider.tsx +1 -5
- package/src/prerender/param-hash.ts +10 -11
- package/src/prerender/store.ts +37 -41
- package/src/prerender.ts +198 -82
- package/src/redirect-origin.ts +100 -0
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -15
- package/src/root-error-boundary.tsx +1 -19
- package/src/route-content-wrapper.tsx +7 -72
- package/src/route-definition/dsl-helpers.ts +437 -274
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +113 -37
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +52 -10
- package/src/route-definition/resolve-handler-use.ts +161 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-map-builder.ts +7 -17
- package/src/route-types.ts +37 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +108 -9
- package/src/router/error-handling.ts +13 -17
- package/src/router/find-match.ts +45 -22
- package/src/router/handler-context.ts +83 -41
- package/src/router/intercept-resolution.ts +25 -23
- package/src/router/lazy-includes.ts +19 -53
- package/src/router/loader-resolution.ts +213 -30
- package/src/router/logging.ts +5 -8
- package/src/router/manifest.ts +49 -45
- package/src/router/match-api.ts +120 -204
- package/src/router/match-context.ts +0 -22
- package/src/router/match-handlers.ts +58 -58
- package/src/router/match-middleware/background-revalidation.ts +27 -6
- package/src/router/match-middleware/cache-lookup.ts +205 -249
- package/src/router/match-middleware/cache-store.ts +45 -32
- package/src/router/match-middleware/intercept-resolution.ts +8 -28
- package/src/router/match-middleware/segment-resolution.ts +52 -18
- package/src/router/match-pipelines.ts +1 -42
- package/src/router/match-result.ts +104 -40
- package/src/router/metrics.ts +5 -34
- package/src/router/middleware-types.ts +13 -142
- package/src/router/middleware.ts +173 -143
- package/src/router/navigation-snapshot.ts +131 -0
- package/src/router/params-util.ts +23 -0
- package/src/router/pattern-matching.ts +109 -63
- package/src/router/prerender-match.ts +190 -54
- package/src/router/preview-match.ts +32 -102
- package/src/router/request-classification.ts +276 -0
- package/src/router/revalidation.ts +63 -55
- package/src/router/route-snapshot.ts +244 -0
- package/src/router/router-context.ts +6 -28
- package/src/router/router-interfaces.ts +100 -35
- package/src/router/router-options.ts +91 -11
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +242 -75
- package/src/router/segment-resolution/helpers.ts +63 -24
- package/src/router/segment-resolution/loader-cache.ts +41 -37
- package/src/router/segment-resolution/revalidation.ts +456 -372
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/segment-resolution.ts +4 -1
- package/src/router/segment-wrappers.ts +2 -3
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry-otel.ts +0 -20
- package/src/router/telemetry.ts +96 -19
- package/src/router/timeout.ts +0 -20
- package/src/router/trie-matching.ts +91 -46
- package/src/router/types.ts +10 -63
- package/src/router/url-params.ts +44 -0
- package/src/router.ts +134 -43
- package/src/rsc/handler-context.ts +3 -2
- package/src/rsc/handler.ts +492 -383
- package/src/rsc/helpers.ts +162 -46
- package/src/rsc/index.ts +1 -1
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +33 -42
- package/src/rsc/origin-guard.ts +39 -25
- package/src/rsc/progressive-enhancement.ts +30 -3
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +90 -63
- package/src/rsc/rsc-rendering.ts +56 -54
- package/src/rsc/runtime-warnings.ts +23 -10
- package/src/rsc/server-action.ts +74 -67
- package/src/rsc/ssr-setup.ts +18 -2
- package/src/rsc/types.ts +25 -6
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +4 -20
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +134 -0
- package/src/segment-system.tsx +272 -129
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +309 -61
- package/src/server/cookie-store.ts +80 -5
- package/src/server/handle-store.ts +26 -24
- package/src/server/loader-registry.ts +10 -28
- package/src/server/request-context.ts +338 -126
- package/src/ssr/index.tsx +23 -15
- package/src/static-handler.ts +27 -18
- package/src/testing/cache-status.ts +162 -0
- package/src/testing/collect-handle.ts +40 -0
- package/src/testing/dispatch.ts +618 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +128 -0
- package/src/testing/e2e/matchers.ts +35 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +97 -0
- package/src/testing/flight-normalize.ts +11 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +232 -0
- package/src/testing/generated-routes.ts +183 -0
- package/src/testing/index.ts +99 -0
- package/src/testing/internal/context.ts +348 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +54 -0
- package/src/testing/render-handler.ts +330 -0
- package/src/testing/render-route.tsx +566 -0
- package/src/testing/run-loader.ts +378 -0
- package/src/testing/run-middleware.ts +205 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/theme/ThemeProvider.tsx +0 -52
- package/src/theme/ThemeScript.tsx +0 -6
- package/src/theme/constants.ts +0 -12
- package/src/theme/index.ts +0 -7
- package/src/theme/theme-context.ts +1 -5
- package/src/theme/theme-script.ts +0 -14
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +0 -35
- package/src/types/cache-types.ts +17 -8
- package/src/types/error-types.ts +30 -90
- package/src/types/global-namespace.ts +54 -41
- package/src/types/handler-context.ts +233 -81
- package/src/types/index.ts +1 -10
- package/src/types/loader-types.ts +44 -15
- package/src/types/request-scope.ts +107 -0
- package/src/types/route-config.ts +6 -50
- package/src/types/route-entry.ts +19 -7
- package/src/types/segments.ts +37 -14
- package/src/urls/include-helper.ts +33 -70
- package/src/urls/index.ts +1 -11
- package/src/urls/path-helper-types.ts +58 -11
- package/src/urls/path-helper.ts +57 -111
- package/src/urls/pattern-types.ts +48 -19
- package/src/urls/response-types.ts +25 -22
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -18
- package/src/use-loader.tsx +346 -89
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +36 -38
- package/src/vite/discovery/discover-routers.ts +130 -85
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +192 -99
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +51 -6
- package/src/vite/discovery/virtual-module-codegen.ts +14 -34
- package/src/vite/index.ts +8 -0
- package/src/vite/plugin-types.ts +187 -69
- package/src/vite/plugins/cjs-to-esm.ts +8 -18
- package/src/vite/plugins/client-ref-dedup.ts +16 -11
- package/src/vite/plugins/client-ref-hashing.ts +28 -15
- 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 +194 -0
- package/src/vite/plugins/expose-action-id.ts +49 -98
- package/src/vite/plugins/expose-id-utils.ts +11 -50
- package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
- package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
- package/src/vite/plugins/expose-internal-ids.ts +554 -317
- package/src/vite/plugins/performance-tracks.ts +89 -0
- package/src/vite/plugins/refresh-cmd.ts +89 -27
- package/src/vite/plugins/use-cache-transform.ts +73 -83
- package/src/vite/plugins/vercel-output.ts +258 -0
- package/src/vite/plugins/version-injector.ts +21 -25
- package/src/vite/plugins/version-plugin.ts +41 -20
- package/src/vite/plugins/virtual-entries.ts +2 -17
- package/src/vite/rango.ts +257 -289
- package/src/vite/router-discovery.ts +930 -140
- package/src/vite/utils/ast-handler-extract.ts +15 -31
- package/src/vite/utils/banner.ts +4 -4
- package/src/vite/utils/bundle-analysis.ts +10 -15
- package/src/vite/utils/client-chunks.ts +184 -0
- package/src/vite/utils/forward-user-plugins.ts +171 -0
- package/src/vite/utils/manifest-utils.ts +4 -59
- package/src/vite/utils/package-resolution.ts +20 -52
- package/src/vite/utils/prerender-utils.ts +27 -29
- package/src/vite/utils/shared-utils.ts +92 -42
- package/src/browser/action-response-classifier.ts +0 -99
- package/src/browser/react/use-client-cache.ts +0 -58
- package/src/browser/shallow.ts +0 -40
- package/src/handles/index.ts +0 -7
- package/src/router/middleware-cookies.ts +0 -55
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { debugLog } from "./logging.js";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Defers a callback to the next animation frame.
|
|
15
|
+
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
+
*/
|
|
17
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
+
typeof requestAnimationFrame === "function"
|
|
19
|
+
? requestAnimationFrame
|
|
20
|
+
: (fn) => setTimeout(fn, 0);
|
|
21
|
+
|
|
13
22
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
|
|
|
264
273
|
return false;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
|
-
//
|
|
268
|
-
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
269
|
-
const canScrollToPosition = savedY <= maxScrollY;
|
|
270
|
-
|
|
271
|
-
if (canScrollToPosition) {
|
|
272
|
-
window.scrollTo(0, savedY);
|
|
273
|
-
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Scroll as far as we can for now
|
|
278
|
-
window.scrollTo(0, maxScrollY);
|
|
279
|
-
debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
280
|
-
|
|
281
|
-
// Poll while streaming until we can scroll to target position
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
282
277
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
283
278
|
const startTime = Date.now();
|
|
284
279
|
|
|
285
280
|
pendingPollInterval = setInterval(() => {
|
|
286
|
-
// Stop if we've exceeded the timeout
|
|
287
281
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
288
282
|
debugLog("[Scroll] Polling timeout, giving up");
|
|
289
283
|
cancelScrollRestorationPolling();
|
|
290
284
|
return;
|
|
291
285
|
}
|
|
292
286
|
|
|
293
|
-
// Stop if streaming ended
|
|
294
287
|
if (!options.isStreaming?.()) {
|
|
295
|
-
debugLog("[Scroll] Streaming ended, stopping poll");
|
|
296
|
-
cancelScrollRestorationPolling();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check if we can now scroll to the target position
|
|
301
|
-
const currentMaxScrollY =
|
|
302
|
-
document.documentElement.scrollHeight - window.innerHeight;
|
|
303
|
-
if (savedY <= currentMaxScrollY) {
|
|
304
288
|
window.scrollTo(0, savedY);
|
|
305
|
-
debugLog("[Scroll]
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
306
290
|
cancelScrollRestorationPolling();
|
|
307
291
|
}
|
|
308
292
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
309
295
|
}
|
|
310
296
|
|
|
311
|
-
|
|
297
|
+
// Not streaming — scroll after React commits and browser paints.
|
|
298
|
+
// startTransition defers the DOM commit, so scrolling synchronously
|
|
299
|
+
// would be overwritten when React replaces the content.
|
|
300
|
+
deferToNextPaint(() => {
|
|
301
|
+
window.scrollTo(0, savedY);
|
|
302
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
312
305
|
}
|
|
313
306
|
|
|
314
307
|
/**
|
|
@@ -339,6 +332,8 @@ export function scrollToHash(): boolean {
|
|
|
339
332
|
* Scroll to top of page
|
|
340
333
|
*/
|
|
341
334
|
export function scrollToTop(): void {
|
|
335
|
+
if (typeof window === "undefined") return;
|
|
336
|
+
if (typeof window.scrollTo !== "function") return;
|
|
342
337
|
window.scrollTo(0, 0);
|
|
343
338
|
}
|
|
344
339
|
|
|
@@ -363,31 +358,43 @@ export function handleNavigationEnd(options: {
|
|
|
363
358
|
scroll?: boolean;
|
|
364
359
|
isStreaming?: () => boolean;
|
|
365
360
|
}): void {
|
|
366
|
-
if (!initialized) {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
361
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
371
362
|
|
|
372
|
-
// Don't scroll if explicitly disabled
|
|
373
|
-
if (scroll === false) {
|
|
363
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
364
|
+
if (scroll === false || typeof window === "undefined") {
|
|
374
365
|
return;
|
|
375
366
|
}
|
|
376
367
|
|
|
377
|
-
//
|
|
378
|
-
|
|
368
|
+
// Save/restore requires initialization (sessionStorage, history state).
|
|
369
|
+
// But basic scroll-to-top and hash scrolling work without it — this
|
|
370
|
+
// matters during cross-app navigation where ScrollRestoration unmounts
|
|
371
|
+
// and remounts, creating a brief window where initialized is false.
|
|
372
|
+
if (restore && initialized) {
|
|
379
373
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
380
374
|
return;
|
|
381
375
|
}
|
|
382
376
|
// Fall through to hash or top if no saved position
|
|
383
377
|
}
|
|
384
378
|
|
|
385
|
-
//
|
|
379
|
+
// scrollToHash / scrollToTop run synchronously here.
|
|
380
|
+
// handleNavigationEnd is invoked from NavigationProvider's
|
|
381
|
+
// useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
|
|
382
|
+
// captured by the upcoming paint AND by startViewTransition's snapshot.
|
|
383
|
+
// Deferring via rAF here pushed the call past the snapshot capture,
|
|
384
|
+
// making forward navigations wrapped in a layout/route view transition
|
|
385
|
+
// skip scroll-to-top — the live DOM scrolled but the captured snapshot
|
|
386
|
+
// was at the previous scroll position, so the user-facing page stayed
|
|
387
|
+
// visually clamped at the source page's scrollY (often the new tree's
|
|
388
|
+
// max scroll for tall→short navs). Y=0 / a hash element are robust
|
|
389
|
+
// against unmeasured layout, so sync scroll is correct here even
|
|
390
|
+
// before the new tree's scrollHeight settles.
|
|
391
|
+
//
|
|
392
|
+
// (The restore branch above keeps deferToNextPaint because savedY
|
|
393
|
+
// depends on the new tree's max scroll; sync scrollTo against an
|
|
394
|
+
// unmeasured DOM would clamp savedY to whatever the old/zero max was.)
|
|
386
395
|
if (scrollToHash()) {
|
|
387
396
|
return;
|
|
388
397
|
}
|
|
389
|
-
|
|
390
|
-
// Default: scroll to top
|
|
391
398
|
scrollToTop();
|
|
392
399
|
}
|
|
393
400
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./merge-segment-loaders.js";
|
|
7
7
|
import { assertSegmentStructure } from "./segment-structure-assert.js";
|
|
8
8
|
import { splitInterceptSegments } from "./intercept-utils.js";
|
|
9
|
+
import { debugLog } from "./logging.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Determines the merging behavior for segment reconciliation.
|
|
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
85
86
|
const cachedSegments = new Map<string, ResolvedSegment>();
|
|
86
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
87
88
|
|
|
89
|
+
const diffSet = new Set(diff);
|
|
90
|
+
debugLog(
|
|
91
|
+
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
92
|
+
);
|
|
93
|
+
debugLog(
|
|
94
|
+
`[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
debugLog(
|
|
97
|
+
`[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
88
100
|
const segments = matched
|
|
89
101
|
.map((segId: string) => {
|
|
90
102
|
const fromServer = serverSegments.get(segId);
|
|
91
103
|
const fromCache = cachedSegments.get(segId);
|
|
92
104
|
|
|
93
105
|
if (fromServer) {
|
|
106
|
+
const inDiff = diffSet.has(segId);
|
|
94
107
|
// Merge partial loader data when server returns fewer loaders than cached
|
|
95
108
|
if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
|
|
109
|
+
debugLog(
|
|
110
|
+
`[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
|
|
111
|
+
);
|
|
96
112
|
return mergeSegmentLoaders(fromServer, fromCache);
|
|
97
113
|
}
|
|
98
114
|
|
|
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
143
159
|
// above fails to preserve a value it should have.
|
|
144
160
|
assertSegmentStructure(fromCache, merged, context);
|
|
145
161
|
|
|
162
|
+
debugLog(
|
|
163
|
+
`[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
|
|
164
|
+
);
|
|
146
165
|
return merged;
|
|
147
166
|
}
|
|
167
|
+
debugLog(
|
|
168
|
+
`[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
|
|
169
|
+
);
|
|
148
170
|
return fromServer;
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -158,15 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
158
180
|
return fromCache;
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
debugLog(
|
|
184
|
+
`[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Return the cached segment as-is, regardless of actor. We used to clear
|
|
188
|
+
// truthy `loading` here to prevent a stale Suspense fallback from
|
|
189
|
+
// committing against cached content, but that swapped the render tree
|
|
190
|
+
// from the LoaderBoundary branch to the plain OutletProvider branch
|
|
191
|
+
// inside renderSegments, causing React to unmount the entire chain
|
|
192
|
+
// (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
|
|
193
|
+
// Suspender) every time the user opened an intercept or navigated back
|
|
194
|
+
// to a cached page. The flicker is now prevented by renderSegments'
|
|
195
|
+
// promise memoization keeping React's use() in "known fulfilled" state,
|
|
196
|
+
// so preserving `loading` keeps the element tree stable.
|
|
170
197
|
return fromCache;
|
|
171
198
|
})
|
|
172
199
|
.filter(Boolean) as ResolvedSegment[];
|
|
@@ -48,7 +48,7 @@ export function assertSegmentStructure(
|
|
|
48
48
|
|
|
49
49
|
if (cachedCategory !== incomingCategory) {
|
|
50
50
|
console.warn(
|
|
51
|
-
`[
|
|
51
|
+
`[Rango] Tree structure mismatch detected in ${context} ` +
|
|
52
52
|
`for segment "${cached.id}": loading category changed from ` +
|
|
53
53
|
`"${cachedCategory}" (${describeLoading(cached.loading)}) to ` +
|
|
54
54
|
`"${incomingCategory}" (${describeLoading(incoming.loading)}). ` +
|
|
@@ -64,7 +64,7 @@ export function assertSegmentStructure(
|
|
|
64
64
|
const incomingHasMount = !!incoming.mountPath;
|
|
65
65
|
if (cachedHasMount !== incomingHasMount) {
|
|
66
66
|
console.warn(
|
|
67
|
-
`[
|
|
67
|
+
`[Rango] MountContextProvider mismatch detected in ${context} ` +
|
|
68
68
|
`for segment "${cached.id}": mountPath changed from ` +
|
|
69
69
|
`${cachedHasMount ? `"${cached.mountPath}"` : "undefined"} to ` +
|
|
70
70
|
`${incomingHasMount ? `"${incoming.mountPath}"` : "undefined"}. ` +
|
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
RscPayload,
|
|
5
5
|
} from "./types.js";
|
|
6
6
|
import { createPartialUpdater } from "./partial-update.js";
|
|
7
|
+
import { enterActionFence, exitActionFence } from "./action-fence.js";
|
|
8
|
+
import { KEEP_CACHE_HEADER } from "./cookie-name.js";
|
|
7
9
|
import { createNavigationTransaction } from "./navigation-transaction.js";
|
|
8
10
|
import {
|
|
9
11
|
reconcileSegments,
|
|
@@ -21,14 +23,20 @@ import {
|
|
|
21
23
|
isBrowserDebugEnabled,
|
|
22
24
|
startBrowserTransaction,
|
|
23
25
|
} from "./logging.js";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
validateRedirectOrigin,
|
|
28
|
+
validateExternalRedirect,
|
|
29
|
+
} from "./validate-redirect-origin.js";
|
|
25
30
|
import {
|
|
26
31
|
extractRscHeaderUrl,
|
|
27
32
|
emptyResponse,
|
|
33
|
+
handleReloadHeader,
|
|
28
34
|
teeWithCompletion,
|
|
35
|
+
isForeignRouterId,
|
|
29
36
|
} from "./response-adapter.js";
|
|
30
37
|
import { mergeLocationState } from "./history-state.js";
|
|
31
38
|
import { classifyActionOutcome } from "./action-coordinator.js";
|
|
39
|
+
import { getAppVersion } from "./app-version.js";
|
|
32
40
|
|
|
33
41
|
// Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
|
|
34
42
|
if (typeof Symbol.dispose === "undefined") {
|
|
@@ -43,8 +51,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
|
|
|
43
51
|
*/
|
|
44
52
|
export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
|
|
45
53
|
eventController: EventController;
|
|
46
|
-
/** RSC version from initial payload metadata */
|
|
47
|
-
version?: string;
|
|
48
54
|
/** Callback to trigger SPA navigation (for action redirects) */
|
|
49
55
|
onNavigate?: (
|
|
50
56
|
url: string,
|
|
@@ -75,10 +81,29 @@ export function createServerActionBridge(
|
|
|
75
81
|
deps,
|
|
76
82
|
onUpdate,
|
|
77
83
|
renderSegments,
|
|
78
|
-
version,
|
|
79
84
|
onNavigate,
|
|
80
85
|
} = config;
|
|
81
86
|
|
|
87
|
+
// SPA-navigate when onNavigate is set, else hard-reload. state is omitted (not
|
|
88
|
+
// passed as undefined) to match the header path's prior call shape.
|
|
89
|
+
// Callers pass an already same-origin-validated url; the hard-reload fallback
|
|
90
|
+
// re-validates defensively so this leaf cannot become an open redirect if a
|
|
91
|
+
// future caller forgets (the SPA path validates inside the navigation bridge).
|
|
92
|
+
async function dispatchRedirect(url: string, state?: unknown): Promise<void> {
|
|
93
|
+
if (onNavigate) {
|
|
94
|
+
await onNavigate(url, {
|
|
95
|
+
...(state !== undefined ? { state } : {}),
|
|
96
|
+
replace: true,
|
|
97
|
+
_skipCache: true,
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
const safe = validateRedirectOrigin(url, window.location.origin);
|
|
101
|
+
if (safe) {
|
|
102
|
+
window.location.href = safe;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
let isRegistered = false;
|
|
83
108
|
|
|
84
109
|
const fetchPartialUpdate = createPartialUpdater({
|
|
@@ -86,7 +111,7 @@ export function createServerActionBridge(
|
|
|
86
111
|
client,
|
|
87
112
|
onUpdate,
|
|
88
113
|
renderSegments,
|
|
89
|
-
|
|
114
|
+
getVersion: getAppVersion,
|
|
90
115
|
});
|
|
91
116
|
|
|
92
117
|
/**
|
|
@@ -142,12 +167,52 @@ export function createServerActionBridge(
|
|
|
142
167
|
|
|
143
168
|
// Start action in event controller - handles lifecycle tracking
|
|
144
169
|
const handle = eventController.startAction(id, args);
|
|
170
|
+
// Whether the action's response carried the keepClientCache() directive.
|
|
171
|
+
// Set when the response arrives; gates the deferred invalidation below.
|
|
172
|
+
let keepCache = false;
|
|
173
|
+
// Whether a Response actually settled from the network (the server saw the
|
|
174
|
+
// request). Set true as the first statement in the fetch .then() below.
|
|
175
|
+
// Gates the automatic invalidation: a pre-dispatch failure (encodeReply
|
|
176
|
+
// throw or a fetch rejection — server unreachable/DNS/connection refused)
|
|
177
|
+
// leaves this false, so finalizeAction() must NOT invalidate or broadcast —
|
|
178
|
+
// nothing reached the server, so nothing could have mutated. A failed Flight
|
|
179
|
+
// DECODE after the response arrived keeps it true (the mutation may have
|
|
180
|
+
// committed, so invalidating the now-possibly-stale client cache is correct).
|
|
181
|
+
let responseReceived = false;
|
|
182
|
+
// Single deferred invalidation + fence release, run exactly ONCE however the
|
|
183
|
+
// action terminates (normal, redirect, error, abort, intercept, concurrent).
|
|
184
|
+
// This replaces main's eager clear at action start: every directive-free
|
|
185
|
+
// action invalidates once; keepClientCache() suppresses only the automatic
|
|
186
|
+
// invalidation, so a concurrent directive-free action still invalidates via
|
|
187
|
+
// its own latch. Latched so the finally AND the early SPA-redirect returns
|
|
188
|
+
// (whose Flight stream never settles) can both call it safely.
|
|
189
|
+
let actionFinalized = false;
|
|
190
|
+
// skipInvalidation: the version-mismatch reload terminal released nothing
|
|
191
|
+
// server-side, so it releases the fence without invalidating.
|
|
192
|
+
const finalizeAction = (skipInvalidation = false): void => {
|
|
193
|
+
if (actionFinalized) return;
|
|
194
|
+
actionFinalized = true;
|
|
195
|
+
// finally so a throw in invalidation cannot leak the fence (latch is set).
|
|
196
|
+
try {
|
|
197
|
+
// responseReceived gates the automatic invalidation: a pre-dispatch
|
|
198
|
+
// failure (serialize throw / fetch reject) never reached the server, so
|
|
199
|
+
// marking the cache stale + broadcasting cross-tab would be spurious.
|
|
200
|
+
if (responseReceived && !keepCache && !skipInvalidation) {
|
|
201
|
+
store.markCacheAsStaleAndBroadcast();
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
exitActionFence();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
145
207
|
try {
|
|
146
208
|
const segmentState = store.getSegmentState();
|
|
147
209
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
store
|
|
210
|
+
// Raise the action fence (replaces the old eager clear). Nothing is wiped,
|
|
211
|
+
// rotated, or broadcast yet: navigations during the flight fetch fresh
|
|
212
|
+
// (no-store) and popstate is treated as SWR, but the decision to
|
|
213
|
+
// invalidate is deferred to the response so a no-op action (keepClientCache)
|
|
214
|
+
// can leave the caches and the jar untouched.
|
|
215
|
+
enterActionFence();
|
|
151
216
|
|
|
152
217
|
// Create temporary references for serialization
|
|
153
218
|
const temporaryReferences = deps.createTemporaryReferenceSet();
|
|
@@ -165,9 +230,15 @@ export function createServerActionBridge(
|
|
|
165
230
|
segmentState.currentSegmentIds.join(","),
|
|
166
231
|
);
|
|
167
232
|
// Add version param for version mismatch detection
|
|
233
|
+
const version = getAppVersion();
|
|
168
234
|
if (version) {
|
|
169
235
|
url.searchParams.set("_rsc_v", version);
|
|
170
236
|
}
|
|
237
|
+
// Add router ID for app switch detection
|
|
238
|
+
const rid = store.getRouterId?.();
|
|
239
|
+
if (rid) {
|
|
240
|
+
url.searchParams.set("_rsc_rid", rid);
|
|
241
|
+
}
|
|
171
242
|
|
|
172
243
|
// Encode arguments
|
|
173
244
|
const encodedBody = await deps.encodeReply(args, { temporaryReferences });
|
|
@@ -206,7 +277,6 @@ export function createServerActionBridge(
|
|
|
206
277
|
"rsc-action": id,
|
|
207
278
|
"X-RSC-Router-Client-Path": segmentState.currentUrl,
|
|
208
279
|
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
209
|
-
// Send intercept source URL so server can maintain intercept context
|
|
210
280
|
...(interceptSourceUrl && {
|
|
211
281
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
212
282
|
}),
|
|
@@ -214,23 +284,34 @@ export function createServerActionBridge(
|
|
|
214
284
|
body: encodedBody,
|
|
215
285
|
signal: fetchAbort.signal,
|
|
216
286
|
}).then(async (response) => {
|
|
287
|
+
// A settled fetch promise means the request reached the server and a
|
|
288
|
+
// Response came back (true for 2xx, 4xx, AND 5xx — fetch only rejects
|
|
289
|
+
// on network-layer failure, never on HTTP status). Record it as the
|
|
290
|
+
// first statement so every downstream terminal can invalidate; a
|
|
291
|
+
// pre-dispatch failure never gets here and stays gated out.
|
|
292
|
+
responseReceived = true;
|
|
217
293
|
// Response arrived — disconnect fetch abort from handle abort so
|
|
218
294
|
// abortAllActions() doesn't disrupt the in-progress Flight stream.
|
|
219
295
|
handle.signal.removeEventListener("abort", onHandleAbort);
|
|
220
296
|
|
|
297
|
+
// Did the action call keepClientCache()? If so the deferred invalidation
|
|
298
|
+
// below is suppressed for THIS action (a concurrent directive-free
|
|
299
|
+
// action still invalidates via its own response).
|
|
300
|
+
keepCache = response.headers.get(KEEP_CACHE_HEADER) === "1";
|
|
301
|
+
|
|
221
302
|
// Check for version mismatch - server wants us to reload
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
303
|
+
const reloadResult = handleReloadHeader(response, {
|
|
304
|
+
onBlocked: resolveStreamComplete,
|
|
305
|
+
onReload: (url) => {
|
|
306
|
+
log("version mismatch on action, reloading", { reloadUrl: url });
|
|
307
|
+
// Never-settling terminal (navigates away), so the finally never
|
|
308
|
+
// runs: release the fence here. skipInvalidation — the mismatch
|
|
309
|
+
// short-circuits the action server-side, so nothing mutated and a
|
|
310
|
+
// broadcast would only risk hard-reloading a sibling mid-task.
|
|
311
|
+
finalizeAction(true);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
if (reloadResult) return reloadResult;
|
|
234
315
|
|
|
235
316
|
// Simple redirect from action (no state, no RSC payload).
|
|
236
317
|
// Short-circuits before createFromFetch — no Flight deserialization needed.
|
|
@@ -240,14 +321,11 @@ export function createServerActionBridge(
|
|
|
240
321
|
if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
|
|
241
322
|
log("action simple redirect", { url: redirect.url });
|
|
242
323
|
handle.complete(undefined);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
} else {
|
|
249
|
-
window.location.href = redirect.url;
|
|
250
|
-
}
|
|
324
|
+
// This path returns a never-settling promise, so the finally never
|
|
325
|
+
// runs: invalidate + release the fence here (the mutation committed
|
|
326
|
+
// and we're navigating away). Latched, so the finally is a no-op.
|
|
327
|
+
finalizeAction();
|
|
328
|
+
await dispatchRedirect(redirect.url);
|
|
251
329
|
return new Promise<Response>(() => {});
|
|
252
330
|
}
|
|
253
331
|
if (redirect === "blocked") {
|
|
@@ -255,6 +333,29 @@ export function createServerActionBridge(
|
|
|
255
333
|
return emptyResponse();
|
|
256
334
|
}
|
|
257
335
|
|
|
336
|
+
// Integrity check (pre-decode): a foreign app's action response must
|
|
337
|
+
// not be decoded + applied here. This is the one decode-and-apply path
|
|
338
|
+
// the post-decode partial-update guard does NOT cover (the action
|
|
339
|
+
// bridge has its own createFromFetch -> onUpdate). Ordered after the
|
|
340
|
+
// reload/redirect handlers, which steer control responses first.
|
|
341
|
+
// Reloads via window.location.reload() rather than navigating to a
|
|
342
|
+
// target (as the navigation-client guard does): an action has no
|
|
343
|
+
// navigation target, so reloading the current URL re-syncs the
|
|
344
|
+
// document against the server-applied action effect.
|
|
345
|
+
if (
|
|
346
|
+
!handle.signal.aborted &&
|
|
347
|
+
isForeignRouterId(response, store.getRouterId?.())
|
|
348
|
+
) {
|
|
349
|
+
log("action router id mismatch, reloading to re-sync");
|
|
350
|
+
handle.complete(undefined);
|
|
351
|
+
resolveStreamComplete();
|
|
352
|
+
// Never-settling return: release the fence before the reload (the
|
|
353
|
+
// reload resets module state anyway, but stay balanced). Latched.
|
|
354
|
+
finalizeAction();
|
|
355
|
+
window.location.reload();
|
|
356
|
+
return new Promise<Response>(() => {});
|
|
357
|
+
}
|
|
358
|
+
|
|
258
359
|
// Start streaming immediately when response arrives
|
|
259
360
|
if (!handle.signal.aborted) {
|
|
260
361
|
streamingToken = handle.startStreaming();
|
|
@@ -309,7 +410,6 @@ export function createServerActionBridge(
|
|
|
309
410
|
matchedCount: payload.metadata?.matched?.length ?? 0,
|
|
310
411
|
diffCount: payload.metadata?.diff?.length ?? 0,
|
|
311
412
|
});
|
|
312
|
-
|
|
313
413
|
// Guard: if the action was aborted while streaming (e.g., user navigated
|
|
314
414
|
// away or abortAllActions fired), bail out before any reconcile/render/cache
|
|
315
415
|
// writes to avoid overwriting the current UI with stale action results.
|
|
@@ -326,6 +426,27 @@ export function createServerActionBridge(
|
|
|
326
426
|
// Check handle.signal.aborted to avoid redirecting from a stale action
|
|
327
427
|
// when the user has already navigated away.
|
|
328
428
|
if (metadata?.redirect && !handle.signal.aborted) {
|
|
429
|
+
// Explicit off-host redirect (redirect(url, { external: true })):
|
|
430
|
+
// hard-navigate, but still scheme-validate (http/https only). external
|
|
431
|
+
// waives the same-origin check, NOT scheme safety, so a forged payload
|
|
432
|
+
// carrying a javascript:/data: URL cannot script via location.assign.
|
|
433
|
+
if (metadata.redirect.external) {
|
|
434
|
+
const externalUrl = validateExternalRedirect(
|
|
435
|
+
metadata.redirect.url,
|
|
436
|
+
window.location.origin,
|
|
437
|
+
);
|
|
438
|
+
if (!externalUrl) {
|
|
439
|
+
log("blocked external action redirect payload", {
|
|
440
|
+
url: metadata.redirect.url,
|
|
441
|
+
});
|
|
442
|
+
handle.complete(returnValue?.data);
|
|
443
|
+
return returnValue?.data;
|
|
444
|
+
}
|
|
445
|
+
log("external action redirect", { url: externalUrl });
|
|
446
|
+
handle.complete(returnValue?.data);
|
|
447
|
+
window.location.assign(externalUrl);
|
|
448
|
+
return returnValue?.data;
|
|
449
|
+
}
|
|
329
450
|
const redirectUrl = validateRedirectOrigin(
|
|
330
451
|
metadata.redirect.url,
|
|
331
452
|
window.location.origin,
|
|
@@ -337,18 +458,9 @@ export function createServerActionBridge(
|
|
|
337
458
|
handle.complete(returnValue?.data);
|
|
338
459
|
return returnValue?.data;
|
|
339
460
|
}
|
|
340
|
-
const redirectState = metadata.locationState;
|
|
341
461
|
log("action redirect", { url: redirectUrl });
|
|
342
462
|
handle.complete(returnValue?.data);
|
|
343
|
-
|
|
344
|
-
await onNavigate(redirectUrl, {
|
|
345
|
-
state: redirectState,
|
|
346
|
-
replace: true,
|
|
347
|
-
_skipCache: true,
|
|
348
|
-
});
|
|
349
|
-
} else {
|
|
350
|
-
window.location.href = redirectUrl;
|
|
351
|
-
}
|
|
463
|
+
await dispatchRedirect(redirectUrl, metadata.locationState);
|
|
352
464
|
return returnValue?.data;
|
|
353
465
|
}
|
|
354
466
|
|
|
@@ -526,8 +638,9 @@ export function createServerActionBridge(
|
|
|
526
638
|
handle.clearConsolidation();
|
|
527
639
|
|
|
528
640
|
if (scenario.historyKeyChanged) {
|
|
529
|
-
|
|
530
|
-
|
|
641
|
+
// Invalidation is deferred to finalizeAction(); here we only trigger
|
|
642
|
+
// the revalidation refetch of the new route (suppressed on keep).
|
|
643
|
+
if (!scenario.onInterceptRoute && !keepCache) {
|
|
531
644
|
refetchRoute().catch((error) => {
|
|
532
645
|
if (isBackgroundSuppressible(error)) return;
|
|
533
646
|
console.error(
|
|
@@ -539,11 +652,14 @@ export function createServerActionBridge(
|
|
|
539
652
|
break;
|
|
540
653
|
}
|
|
541
654
|
|
|
542
|
-
// Same history key but different pathname - safe to refetch current
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
655
|
+
// Same history key but different pathname - safe to refetch current
|
|
656
|
+
// route. Invalidation is deferred to finalizeAction(); here we only
|
|
657
|
+
// trigger the revalidation refetch (suppressed on keep).
|
|
658
|
+
if (!keepCache) {
|
|
659
|
+
await refetchRoute({
|
|
660
|
+
interceptSourceUrl: store.getInterceptSourceUrl(),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
547
663
|
break;
|
|
548
664
|
}
|
|
549
665
|
|
|
@@ -551,8 +667,11 @@ export function createServerActionBridge(
|
|
|
551
667
|
console.warn(
|
|
552
668
|
`[Browser] Missing segments after action (HMR detected), refetching...`,
|
|
553
669
|
);
|
|
670
|
+
// Repair (not revalidation), so ungated on keepCache: a keep action
|
|
671
|
+
// resolving last must discharge a directive-free sibling's repair.
|
|
672
|
+
// See the keep row in docs/design/rango-state-cookie.md (the all-keep
|
|
673
|
+
// edge, and the benign re-mark-stale-after-refetch end-state delta).
|
|
554
674
|
await refetchRoute({ interceptSourceUrl });
|
|
555
|
-
store.broadcastCacheInvalidation();
|
|
556
675
|
break;
|
|
557
676
|
}
|
|
558
677
|
|
|
@@ -569,11 +688,11 @@ export function createServerActionBridge(
|
|
|
569
688
|
// Clear consolidation tracking before fetch
|
|
570
689
|
handle.clearConsolidation();
|
|
571
690
|
|
|
691
|
+
// Ungated on keepCache, same as hmr-missing above (see the keep row).
|
|
572
692
|
await refetchRoute({
|
|
573
693
|
segments: segmentsToSend,
|
|
574
694
|
interceptSourceUrl,
|
|
575
695
|
});
|
|
576
|
-
store.broadcastCacheInvalidation();
|
|
577
696
|
break;
|
|
578
697
|
}
|
|
579
698
|
|
|
@@ -637,7 +756,9 @@ export function createServerActionBridge(
|
|
|
637
756
|
fullSegments,
|
|
638
757
|
currentHandleData,
|
|
639
758
|
);
|
|
640
|
-
|
|
759
|
+
// Invalidation deferred to finalizeAction() (runs after this caches
|
|
760
|
+
// the fresh segments), suppressed when the action called
|
|
761
|
+
// keepClientCache().
|
|
641
762
|
break;
|
|
642
763
|
}
|
|
643
764
|
}
|
|
@@ -645,6 +766,11 @@ export function createServerActionBridge(
|
|
|
645
766
|
handle.complete(returnData);
|
|
646
767
|
return returnData;
|
|
647
768
|
} finally {
|
|
769
|
+
// The single deferred invalidation + fence release for this action. Runs
|
|
770
|
+
// for every terminal that settles (normal, navigated-away, error, abort,
|
|
771
|
+
// intercept, concurrent); the SPA-redirect paths above already ran it.
|
|
772
|
+
// Latched, so it fires exactly once.
|
|
773
|
+
finalizeAction();
|
|
648
774
|
handle[Symbol.dispose]();
|
|
649
775
|
}
|
|
650
776
|
}
|