@rangojs/router 0.0.0-experimental.31 → 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.
Files changed (376) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +198 -44
  3. package/dist/bin/rango.js +287 -105
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3248 -1117
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +73 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +107 -1
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +245 -21
  12. package/skills/caching/SKILL.md +302 -6
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +364 -0
  17. package/skills/hooks/SKILL.md +270 -30
  18. package/skills/host-router/SKILL.md +82 -22
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +49 -5
  21. package/skills/layout/SKILL.md +35 -9
  22. package/skills/links/SKILL.md +249 -17
  23. package/skills/loader/SKILL.md +294 -30
  24. package/skills/middleware/SKILL.md +52 -13
  25. package/skills/migrate-nextjs/SKILL.md +584 -0
  26. package/skills/migrate-react-router/SKILL.md +769 -0
  27. package/skills/mime-routes/SKILL.md +27 -0
  28. package/skills/observability/SKILL.md +137 -0
  29. package/skills/parallel/SKILL.md +203 -7
  30. package/skills/prerender/SKILL.md +123 -100
  31. package/skills/rango/SKILL.md +250 -22
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +122 -47
  34. package/skills/route/SKILL.md +97 -5
  35. package/skills/router-setup/SKILL.md +90 -5
  36. package/skills/server-actions/SKILL.md +775 -0
  37. package/skills/streams-and-websockets/SKILL.md +283 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +129 -0
  40. package/skills/testing/bindings.md +89 -0
  41. package/skills/testing/cache-prerender.md +124 -0
  42. package/skills/testing/client-components.md +122 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +92 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +121 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +120 -0
  54. package/skills/typesafety/SKILL.md +329 -27
  55. package/skills/use-cache/SKILL.md +36 -5
  56. package/skills/view-transitions/SKILL.md +294 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +67 -40
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/app-version.ts +14 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +86 -147
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/link-interceptor.ts +4 -0
  70. package/src/browser/navigation-bridge.ts +148 -19
  71. package/src/browser/navigation-client.ts +187 -67
  72. package/src/browser/navigation-store-handle.ts +38 -0
  73. package/src/browser/navigation-store.ts +76 -67
  74. package/src/browser/navigation-transaction.ts +18 -66
  75. package/src/browser/partial-update.ts +123 -94
  76. package/src/browser/prefetch/cache.ts +214 -36
  77. package/src/browser/prefetch/fetch.ts +260 -38
  78. package/src/browser/prefetch/policy.ts +6 -0
  79. package/src/browser/prefetch/queue.ts +126 -20
  80. package/src/browser/prefetch/resource-ready.ts +77 -0
  81. package/src/browser/rango-state.ts +158 -76
  82. package/src/browser/react/Link.tsx +93 -11
  83. package/src/browser/react/NavigationProvider.tsx +115 -34
  84. package/src/browser/react/ScrollRestoration.tsx +10 -6
  85. package/src/browser/react/context.ts +7 -2
  86. package/src/browser/react/filter-segment-order.ts +49 -7
  87. package/src/browser/react/index.ts +0 -48
  88. package/src/browser/react/location-state-shared.ts +166 -8
  89. package/src/browser/react/location-state.ts +39 -14
  90. package/src/browser/react/use-action.ts +6 -15
  91. package/src/browser/react/use-handle.ts +23 -69
  92. package/src/browser/react/use-link-status.ts +0 -4
  93. package/src/browser/react/use-navigation.ts +22 -5
  94. package/src/browser/react/use-params.ts +20 -10
  95. package/src/browser/react/use-reverse.ts +106 -0
  96. package/src/browser/react/use-router.ts +46 -11
  97. package/src/browser/react/use-search-params.ts +0 -5
  98. package/src/browser/react/use-segments.ts +11 -21
  99. package/src/browser/response-adapter.ts +52 -1
  100. package/src/browser/rsc-router.tsx +215 -76
  101. package/src/browser/scroll-restoration.ts +46 -39
  102. package/src/browser/segment-reconciler.ts +36 -9
  103. package/src/browser/segment-structure-assert.ts +2 -2
  104. package/src/browser/server-action-bridge.ts +176 -50
  105. package/src/browser/types.ts +95 -11
  106. package/src/browser/validate-redirect-origin.ts +43 -16
  107. package/src/build/collect-fallback-refs.ts +107 -0
  108. package/src/build/generate-manifest.ts +65 -40
  109. package/src/build/generate-route-types.ts +5 -0
  110. package/src/build/index.ts +8 -2
  111. package/src/build/prefix-tree-utils.ts +123 -0
  112. package/src/build/route-trie.ts +137 -32
  113. package/src/build/route-types/codegen.ts +4 -4
  114. package/src/build/route-types/include-resolution.ts +9 -2
  115. package/src/build/route-types/param-extraction.ts +6 -3
  116. package/src/build/route-types/per-module-writer.ts +7 -4
  117. package/src/build/route-types/router-processing.ts +278 -96
  118. package/src/build/route-types/scan-filter.ts +9 -2
  119. package/src/build/route-types/source-scan.ts +118 -0
  120. package/src/build/runtime-discovery.ts +9 -20
  121. package/src/cache/cache-error.ts +104 -0
  122. package/src/cache/cache-policy.ts +68 -28
  123. package/src/cache/cache-runtime.ts +149 -43
  124. package/src/cache/cache-scope.ts +148 -81
  125. package/src/cache/cache-tag.ts +98 -0
  126. package/src/cache/cf/cf-cache-store.ts +2550 -93
  127. package/src/cache/cf/index.ts +11 -17
  128. package/src/cache/document-cache.ts +78 -27
  129. package/src/cache/handle-snapshot.ts +63 -0
  130. package/src/cache/index.ts +23 -20
  131. package/src/cache/memory-segment-store.ts +136 -37
  132. package/src/cache/profile-registry.ts +6 -30
  133. package/src/cache/read-through-swr.ts +41 -11
  134. package/src/cache/segment-codec.ts +0 -16
  135. package/src/cache/tag-invalidation.ts +230 -0
  136. package/src/cache/taint.ts +55 -0
  137. package/src/cache/types.ts +33 -100
  138. package/src/cache/vercel/index.ts +11 -0
  139. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  140. package/src/client.rsc.tsx +6 -21
  141. package/src/client.tsx +108 -290
  142. package/src/component-utils.ts +19 -0
  143. package/src/context-var.ts +84 -2
  144. package/src/debug.ts +2 -2
  145. package/src/decode-loader-results.ts +36 -0
  146. package/src/defer.ts +196 -0
  147. package/src/deps/ssr.ts +0 -1
  148. package/src/errors.ts +30 -4
  149. package/src/handle.ts +70 -22
  150. package/src/handles/MetaTags.tsx +0 -14
  151. package/src/handles/breadcrumbs.ts +16 -5
  152. package/src/handles/meta.ts +0 -39
  153. package/src/host/cookie-handler.ts +0 -36
  154. package/src/host/errors.ts +0 -24
  155. package/src/host/index.ts +8 -2
  156. package/src/host/pattern-matcher.ts +7 -50
  157. package/src/host/router.ts +107 -99
  158. package/src/host/testing.ts +40 -27
  159. package/src/host/types.ts +37 -4
  160. package/src/host/utils.ts +1 -1
  161. package/src/href-client.ts +137 -22
  162. package/src/index.rsc.ts +52 -26
  163. package/src/index.ts +100 -38
  164. package/src/internal-debug.ts +2 -4
  165. package/src/loader-store.ts +500 -0
  166. package/src/loader.rsc.ts +20 -13
  167. package/src/loader.ts +12 -11
  168. package/src/missing-id-error.ts +68 -0
  169. package/src/network-error-thrower.tsx +1 -6
  170. package/src/outlet-context.ts +1 -1
  171. package/src/outlet-provider.tsx +1 -5
  172. package/src/prerender/param-hash.ts +10 -11
  173. package/src/prerender/store.ts +37 -41
  174. package/src/prerender.ts +198 -82
  175. package/src/redirect-origin.ts +100 -0
  176. package/src/response-utils.ts +37 -0
  177. package/src/reverse.ts +65 -15
  178. package/src/root-error-boundary.tsx +1 -19
  179. package/src/route-content-wrapper.tsx +7 -72
  180. package/src/route-definition/dsl-helpers.ts +437 -274
  181. package/src/route-definition/helper-factories.ts +29 -139
  182. package/src/route-definition/helpers-types.ts +113 -37
  183. package/src/route-definition/index.ts +3 -0
  184. package/src/route-definition/redirect.ts +52 -10
  185. package/src/route-definition/resolve-handler-use.ts +161 -0
  186. package/src/route-definition/use-item-types.ts +32 -0
  187. package/src/route-map-builder.ts +7 -17
  188. package/src/route-types.ts +37 -41
  189. package/src/router/basename.ts +14 -0
  190. package/src/router/content-negotiation.ts +108 -9
  191. package/src/router/error-handling.ts +13 -17
  192. package/src/router/find-match.ts +45 -22
  193. package/src/router/handler-context.ts +83 -41
  194. package/src/router/intercept-resolution.ts +25 -23
  195. package/src/router/lazy-includes.ts +19 -53
  196. package/src/router/loader-resolution.ts +213 -30
  197. package/src/router/logging.ts +5 -8
  198. package/src/router/manifest.ts +49 -45
  199. package/src/router/match-api.ts +121 -205
  200. package/src/router/match-context.ts +0 -22
  201. package/src/router/match-handlers.ts +58 -58
  202. package/src/router/match-middleware/background-revalidation.ts +27 -6
  203. package/src/router/match-middleware/cache-lookup.ts +205 -249
  204. package/src/router/match-middleware/cache-store.ts +45 -32
  205. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  206. package/src/router/match-middleware/segment-resolution.ts +52 -18
  207. package/src/router/match-pipelines.ts +1 -42
  208. package/src/router/match-result.ts +104 -40
  209. package/src/router/metrics.ts +5 -34
  210. package/src/router/middleware-types.ts +13 -142
  211. package/src/router/middleware.ts +173 -143
  212. package/src/router/navigation-snapshot.ts +131 -0
  213. package/src/router/params-util.ts +23 -0
  214. package/src/router/pattern-matching.ts +109 -63
  215. package/src/router/prerender-match.ts +192 -54
  216. package/src/router/preview-match.ts +32 -102
  217. package/src/router/request-classification.ts +276 -0
  218. package/src/router/revalidation.ts +63 -55
  219. package/src/router/route-snapshot.ts +244 -0
  220. package/src/router/router-context.ts +6 -28
  221. package/src/router/router-interfaces.ts +100 -35
  222. package/src/router/router-options.ts +91 -11
  223. package/src/router/router-registry.ts +2 -5
  224. package/src/router/segment-resolution/fresh.ts +242 -75
  225. package/src/router/segment-resolution/helpers.ts +64 -25
  226. package/src/router/segment-resolution/loader-cache.ts +41 -37
  227. package/src/router/segment-resolution/revalidation.ts +456 -372
  228. package/src/router/segment-resolution/static-store.ts +19 -5
  229. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  230. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  231. package/src/router/segment-resolution.ts +4 -1
  232. package/src/router/segment-wrappers.ts +2 -3
  233. package/src/router/state-cookie-name.ts +33 -0
  234. package/src/router/substitute-pattern-params.ts +56 -0
  235. package/src/router/telemetry-otel.ts +0 -20
  236. package/src/router/telemetry.ts +96 -19
  237. package/src/router/timeout.ts +0 -20
  238. package/src/router/trie-matching.ts +91 -46
  239. package/src/router/types.ts +10 -63
  240. package/src/router/url-params.ts +44 -0
  241. package/src/router.ts +134 -43
  242. package/src/rsc/handler-context.ts +3 -2
  243. package/src/rsc/handler.ts +492 -383
  244. package/src/rsc/helpers.ts +162 -46
  245. package/src/rsc/index.ts +1 -1
  246. package/src/rsc/json-route-result.ts +38 -0
  247. package/src/rsc/loader-fetch.ts +23 -3
  248. package/src/rsc/manifest-init.ts +33 -42
  249. package/src/rsc/origin-guard.ts +39 -25
  250. package/src/rsc/progressive-enhancement.ts +30 -3
  251. package/src/rsc/redirect-guard.ts +99 -0
  252. package/src/rsc/response-error.ts +79 -12
  253. package/src/rsc/response-route-handler.ts +90 -63
  254. package/src/rsc/rsc-rendering.ts +56 -54
  255. package/src/rsc/runtime-warnings.ts +23 -10
  256. package/src/rsc/server-action.ts +74 -67
  257. package/src/rsc/ssr-setup.ts +18 -2
  258. package/src/rsc/types.ts +25 -6
  259. package/src/runtime-env.ts +18 -0
  260. package/src/search-params.ts +4 -20
  261. package/src/segment-content-promise.ts +67 -0
  262. package/src/segment-loader-promise.ts +134 -0
  263. package/src/segment-system.tsx +272 -129
  264. package/src/serialize.ts +243 -0
  265. package/src/server/context.ts +309 -61
  266. package/src/server/cookie-store.ts +80 -5
  267. package/src/server/handle-store.ts +26 -24
  268. package/src/server/loader-registry.ts +10 -28
  269. package/src/server/request-context.ts +348 -128
  270. package/src/ssr/index.tsx +23 -15
  271. package/src/static-handler.ts +27 -18
  272. package/src/testing/cache-status.ts +162 -0
  273. package/src/testing/collect-handle.ts +40 -0
  274. package/src/testing/dispatch.ts +618 -0
  275. package/src/testing/dom.entry.ts +22 -0
  276. package/src/testing/e2e/fixture.ts +188 -0
  277. package/src/testing/e2e/index.ts +128 -0
  278. package/src/testing/e2e/matchers.ts +35 -0
  279. package/src/testing/e2e/page-helpers.ts +272 -0
  280. package/src/testing/e2e/parity.ts +387 -0
  281. package/src/testing/e2e/server.ts +195 -0
  282. package/src/testing/flight-matchers.ts +97 -0
  283. package/src/testing/flight-normalize.ts +11 -0
  284. package/src/testing/flight-runtime.d.ts +57 -0
  285. package/src/testing/flight-tree.ts +682 -0
  286. package/src/testing/flight.entry.ts +52 -0
  287. package/src/testing/flight.ts +232 -0
  288. package/src/testing/generated-routes.ts +183 -0
  289. package/src/testing/index.ts +99 -0
  290. package/src/testing/internal/context.ts +348 -0
  291. package/src/testing/internal/flight-client-globals.ts +30 -0
  292. package/src/testing/internal/seed-vars.ts +54 -0
  293. package/src/testing/render-handler.ts +330 -0
  294. package/src/testing/render-route.tsx +566 -0
  295. package/src/testing/run-loader.ts +378 -0
  296. package/src/testing/run-middleware.ts +205 -0
  297. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  298. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  299. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  300. package/src/testing/vitest-stubs/version.ts +5 -0
  301. package/src/testing/vitest.ts +305 -0
  302. package/src/theme/ThemeProvider.tsx +0 -52
  303. package/src/theme/ThemeScript.tsx +0 -6
  304. package/src/theme/constants.ts +0 -12
  305. package/src/theme/index.ts +0 -7
  306. package/src/theme/theme-context.ts +1 -5
  307. package/src/theme/theme-script.ts +0 -14
  308. package/src/theme/use-theme.ts +0 -3
  309. package/src/types/boundaries.ts +0 -35
  310. package/src/types/cache-types.ts +17 -8
  311. package/src/types/error-types.ts +30 -90
  312. package/src/types/global-namespace.ts +54 -41
  313. package/src/types/handler-context.ts +233 -81
  314. package/src/types/index.ts +1 -10
  315. package/src/types/loader-types.ts +44 -15
  316. package/src/types/request-scope.ts +107 -0
  317. package/src/types/route-config.ts +6 -50
  318. package/src/types/route-entry.ts +19 -7
  319. package/src/types/segments.ts +37 -14
  320. package/src/urls/include-helper.ts +33 -70
  321. package/src/urls/index.ts +1 -11
  322. package/src/urls/path-helper-types.ts +58 -11
  323. package/src/urls/path-helper.ts +57 -111
  324. package/src/urls/pattern-types.ts +48 -19
  325. package/src/urls/response-types.ts +25 -22
  326. package/src/urls/type-extraction.ts +58 -139
  327. package/src/urls/urls-function.ts +1 -18
  328. package/src/use-loader.tsx +346 -89
  329. package/src/vite/debug.ts +185 -0
  330. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  331. package/src/vite/discovery/discover-routers.ts +130 -85
  332. package/src/vite/discovery/discovery-errors.ts +194 -0
  333. package/src/vite/discovery/gate-state.ts +171 -0
  334. package/src/vite/discovery/prerender-collection.ts +192 -99
  335. package/src/vite/discovery/route-types-writer.ts +40 -84
  336. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  337. package/src/vite/discovery/state.ts +51 -6
  338. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  339. package/src/vite/index.ts +8 -0
  340. package/src/vite/plugin-types.ts +187 -69
  341. package/src/vite/plugins/cjs-to-esm.ts +8 -18
  342. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  343. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  344. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  345. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  346. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  347. package/src/vite/plugins/expose-action-id.ts +49 -98
  348. package/src/vite/plugins/expose-id-utils.ts +11 -50
  349. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  350. package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
  351. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  352. package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
  353. package/src/vite/plugins/expose-internal-ids.ts +554 -317
  354. package/src/vite/plugins/performance-tracks.ts +89 -0
  355. package/src/vite/plugins/refresh-cmd.ts +89 -27
  356. package/src/vite/plugins/use-cache-transform.ts +73 -83
  357. package/src/vite/plugins/vercel-output.ts +258 -0
  358. package/src/vite/plugins/version-injector.ts +21 -25
  359. package/src/vite/plugins/version-plugin.ts +41 -20
  360. package/src/vite/plugins/virtual-entries.ts +2 -17
  361. package/src/vite/rango.ts +257 -289
  362. package/src/vite/router-discovery.ts +930 -140
  363. package/src/vite/utils/ast-handler-extract.ts +15 -31
  364. package/src/vite/utils/banner.ts +4 -4
  365. package/src/vite/utils/bundle-analysis.ts +10 -15
  366. package/src/vite/utils/client-chunks.ts +184 -0
  367. package/src/vite/utils/forward-user-plugins.ts +171 -0
  368. package/src/vite/utils/manifest-utils.ts +4 -59
  369. package/src/vite/utils/package-resolution.ts +20 -52
  370. package/src/vite/utils/prerender-utils.ts +27 -29
  371. package/src/vite/utils/shared-utils.ts +92 -42
  372. package/src/browser/action-response-classifier.ts +0 -99
  373. package/src/browser/react/use-client-cache.ts +0 -58
  374. package/src/browser/shallow.ts +0 -40
  375. package/src/handles/index.ts +0 -7
  376. 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
- // Check if page is tall enough to scroll to saved position
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] Poll restored position:", savedY);
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
- return false;
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
- // For back/forward (restore), try to restore saved position
378
- if (restore) {
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
- // Try hash scrolling first
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
- // For non-action actors: cached segments the server decided not to re-render.
162
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Clear truthy loading (active skeleton) to prevent suspense on cached content
164
- if (actor !== "action") {
165
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
166
- return { ...fromCache, loading: undefined };
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
- `[RSC Router] Tree structure mismatch detected in ${context} ` +
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
- `[RSC Router] MountContextProvider mismatch detected in ${context} ` +
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 { validateRedirectOrigin } from "./validate-redirect-origin.js";
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
- version,
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
- // Mark cache as stale immediately when action starts
149
- // This ensures SWR pattern kicks in if user navigates away during action
150
- store.markCacheAsStaleAndBroadcast();
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 reload = extractRscHeaderUrl(response, "X-RSC-Reload");
223
- if (reload === "blocked") {
224
- resolveStreamComplete();
225
- return emptyResponse();
226
- }
227
- if (reload) {
228
- log("version mismatch on action, reloading", {
229
- reloadUrl: reload.url,
230
- });
231
- window.location.href = reload.url;
232
- return new Promise<Response>(() => {});
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
- if (onNavigate) {
244
- await onNavigate(redirect.url, {
245
- replace: true,
246
- _skipCache: true,
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
- if (onNavigate) {
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
- if (!scenario.onInterceptRoute) {
530
- store.markCacheAsStaleAndBroadcast();
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 route
543
- store.markCacheAsStaleAndBroadcast();
544
- await refetchRoute({
545
- interceptSourceUrl: store.getInterceptSourceUrl(),
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
- store.markCacheAsStaleAndBroadcast();
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
  }