@nifrajs/web 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (457) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/boundary.d.ts +67 -7
  3. package/dist/boundary.d.ts.map +1 -1
  4. package/dist/boundary.js +6 -4
  5. package/dist/boundary.js.map +1 -1
  6. package/dist/build-plan.d.ts +3 -0
  7. package/dist/build-plan.d.ts.map +1 -1
  8. package/dist/build-plan.js.map +1 -1
  9. package/dist/build-vite.d.ts.map +1 -1
  10. package/dist/build-vite.js +2 -0
  11. package/dist/build-vite.js.map +1 -1
  12. package/dist/build.d.ts +9 -2
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +9 -0
  15. package/dist/build.js.map +1 -1
  16. package/dist/client.d.ts +2 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +140 -12
  19. package/dist/client.js.map +1 -1
  20. package/dist/csp.d.ts +8 -0
  21. package/dist/csp.d.ts.map +1 -1
  22. package/dist/csp.js +11 -2
  23. package/dist/csp.js.map +1 -1
  24. package/dist/deferred.d.ts +6 -4
  25. package/dist/deferred.d.ts.map +1 -1
  26. package/dist/deferred.js +10 -6
  27. package/dist/deferred.js.map +1 -1
  28. package/dist/dev-feed.d.ts.map +1 -1
  29. package/dist/dev-feed.js +11 -2
  30. package/dist/dev-feed.js.map +1 -1
  31. package/dist/dev-session.d.ts.map +1 -1
  32. package/dist/dev-session.js +13 -6
  33. package/dist/dev-session.js.map +1 -1
  34. package/dist/dev.d.ts +4 -1
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +12 -5
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic-catalog.d.ts +4 -0
  39. package/dist/diagnostic-catalog.d.ts.map +1 -1
  40. package/dist/diagnostic-catalog.js +48 -0
  41. package/dist/diagnostic-catalog.js.map +1 -1
  42. package/dist/diagnostic.d.ts +1 -1
  43. package/dist/diagnostic.d.ts.map +1 -1
  44. package/dist/diagnostic.js +1 -1
  45. package/dist/diagnostic.js.map +1 -1
  46. package/dist/fn.d.ts +5 -3
  47. package/dist/fn.d.ts.map +1 -1
  48. package/dist/fn.js +20 -5
  49. package/dist/fn.js.map +1 -1
  50. package/dist/fs.d.ts.map +1 -1
  51. package/dist/fs.js +13 -3
  52. package/dist/fs.js.map +1 -1
  53. package/dist/identity.d.ts +22 -0
  54. package/dist/identity.d.ts.map +1 -0
  55. package/dist/identity.js +60 -0
  56. package/dist/identity.js.map +1 -0
  57. package/dist/index.d.ts +7 -6
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/internal/cdn/cloudflare.d.ts +2 -1
  62. package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
  63. package/dist/internal/cdn/cloudflare.js +2 -1
  64. package/dist/internal/cdn/cloudflare.js.map +1 -1
  65. package/dist/internal/certified-components.d.ts +1 -1
  66. package/dist/internal/certified-components.js +1 -1
  67. package/dist/internal/codegen.d.ts +11 -10
  68. package/dist/internal/codegen.d.ts.map +1 -1
  69. package/dist/internal/codegen.js +65 -8
  70. package/dist/internal/codegen.js.map +1 -1
  71. package/dist/internal/dev-websocket.d.ts +73 -0
  72. package/dist/internal/dev-websocket.d.ts.map +1 -0
  73. package/dist/internal/dev-websocket.js +145 -0
  74. package/dist/internal/dev-websocket.js.map +1 -0
  75. package/dist/internal/hole-runtime.d.ts +66 -0
  76. package/dist/internal/hole-runtime.d.ts.map +1 -0
  77. package/dist/internal/hole-runtime.js +211 -0
  78. package/dist/internal/hole-runtime.js.map +1 -0
  79. package/dist/internal/identity-epoch.d.ts +50 -0
  80. package/dist/internal/identity-epoch.d.ts.map +1 -0
  81. package/dist/internal/identity-epoch.js +179 -0
  82. package/dist/internal/identity-epoch.js.map +1 -0
  83. package/dist/internal/invalidation-contract.d.ts +23 -0
  84. package/dist/internal/invalidation-contract.d.ts.map +1 -0
  85. package/dist/internal/invalidation-contract.js +11 -0
  86. package/dist/internal/invalidation-contract.js.map +1 -0
  87. package/dist/internal/invalidation.d.ts +62 -0
  88. package/dist/internal/invalidation.d.ts.map +1 -0
  89. package/dist/internal/invalidation.js +149 -0
  90. package/dist/internal/invalidation.js.map +1 -0
  91. package/dist/internal/island-bindings.d.ts +51 -0
  92. package/dist/internal/island-bindings.d.ts.map +1 -0
  93. package/dist/internal/island-bindings.js +147 -0
  94. package/dist/internal/island-bindings.js.map +1 -0
  95. package/dist/internal/island-list.d.ts +26 -0
  96. package/dist/internal/island-list.d.ts.map +1 -0
  97. package/dist/internal/island-list.js +78 -0
  98. package/dist/internal/island-list.js.map +1 -0
  99. package/dist/internal/island-mount.d.ts +4 -0
  100. package/dist/internal/island-mount.d.ts.map +1 -0
  101. package/dist/internal/island-mount.js +66 -0
  102. package/dist/internal/island-mount.js.map +1 -0
  103. package/dist/internal/island-owner.d.ts +5 -0
  104. package/dist/internal/island-owner.d.ts.map +1 -0
  105. package/dist/internal/island-owner.js +20 -0
  106. package/dist/internal/island-owner.js.map +1 -0
  107. package/dist/internal/island-resource.d.ts +30 -0
  108. package/dist/internal/island-resource.d.ts.map +1 -0
  109. package/dist/internal/island-resource.js +57 -0
  110. package/dist/internal/island-resource.js.map +1 -0
  111. package/dist/internal/island-signals.d.ts +51 -0
  112. package/dist/internal/island-signals.d.ts.map +1 -0
  113. package/dist/internal/island-signals.js +220 -0
  114. package/dist/internal/island-signals.js.map +1 -0
  115. package/dist/internal/keep-alive-table.d.ts +23 -0
  116. package/dist/internal/keep-alive-table.d.ts.map +1 -0
  117. package/dist/internal/keep-alive-table.js +53 -0
  118. package/dist/internal/keep-alive-table.js.map +1 -0
  119. package/dist/internal/live-contract.d.ts +96 -0
  120. package/dist/internal/live-contract.d.ts.map +1 -0
  121. package/dist/internal/live-contract.js +5 -0
  122. package/dist/internal/live-contract.js.map +1 -0
  123. package/dist/internal/live-queries.d.ts +22 -0
  124. package/dist/internal/live-queries.d.ts.map +1 -0
  125. package/dist/internal/live-queries.js +54 -0
  126. package/dist/internal/live-queries.js.map +1 -0
  127. package/dist/internal/page-events.d.ts +10 -0
  128. package/dist/internal/page-events.d.ts.map +1 -0
  129. package/dist/internal/page-events.js +26 -0
  130. package/dist/internal/page-events.js.map +1 -0
  131. package/dist/internal/page-execution.d.ts +14 -1
  132. package/dist/internal/page-execution.d.ts.map +1 -1
  133. package/dist/internal/page-execution.js +415 -31
  134. package/dist/internal/page-execution.js.map +1 -1
  135. package/dist/internal/ppr/admission.d.ts +83 -0
  136. package/dist/internal/ppr/admission.d.ts.map +1 -0
  137. package/dist/internal/ppr/admission.js +221 -0
  138. package/dist/internal/ppr/admission.js.map +1 -0
  139. package/dist/internal/ppr/canary.d.ts +24 -0
  140. package/dist/internal/ppr/canary.d.ts.map +1 -0
  141. package/dist/internal/ppr/canary.js +102 -0
  142. package/dist/internal/ppr/canary.js.map +1 -0
  143. package/dist/internal/ppr/compile-shell.d.ts +39 -0
  144. package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
  145. package/dist/internal/ppr/compile-shell.js +298 -0
  146. package/dist/internal/ppr/compile-shell.js.map +1 -0
  147. package/dist/internal/ppr/contract.d.ts +177 -0
  148. package/dist/internal/ppr/contract.d.ts.map +1 -0
  149. package/dist/internal/ppr/contract.js +80 -0
  150. package/dist/internal/ppr/contract.js.map +1 -0
  151. package/dist/internal/ppr/frames.d.ts +71 -0
  152. package/dist/internal/ppr/frames.d.ts.map +1 -0
  153. package/dist/internal/ppr/frames.js +119 -0
  154. package/dist/internal/ppr/frames.js.map +1 -0
  155. package/dist/internal/ppr/integration.d.ts +19 -0
  156. package/dist/internal/ppr/integration.d.ts.map +1 -0
  157. package/dist/internal/ppr/integration.js +106 -0
  158. package/dist/internal/ppr/integration.js.map +1 -0
  159. package/dist/internal/ppr/options.d.ts +96 -0
  160. package/dist/internal/ppr/options.d.ts.map +1 -0
  161. package/dist/internal/ppr/options.js +125 -0
  162. package/dist/internal/ppr/options.js.map +1 -0
  163. package/dist/internal/ppr/route-rules.d.ts +12 -0
  164. package/dist/internal/ppr/route-rules.d.ts.map +1 -0
  165. package/dist/internal/ppr/route-rules.js +53 -0
  166. package/dist/internal/ppr/route-rules.js.map +1 -0
  167. package/dist/internal/ppr/runtime.d.ts +29 -0
  168. package/dist/internal/ppr/runtime.d.ts.map +1 -0
  169. package/dist/internal/ppr/runtime.js +32 -0
  170. package/dist/internal/ppr/runtime.js.map +1 -0
  171. package/dist/internal/ppr/server.d.ts +29 -0
  172. package/dist/internal/ppr/server.d.ts.map +1 -0
  173. package/dist/internal/ppr/server.js +1407 -0
  174. package/dist/internal/ppr/server.js.map +1 -0
  175. package/dist/internal/ppr/shell-store.d.ts +38 -0
  176. package/dist/internal/ppr/shell-store.d.ts.map +1 -0
  177. package/dist/internal/ppr/shell-store.js +90 -0
  178. package/dist/internal/ppr/shell-store.js.map +1 -0
  179. package/dist/internal/prerendered-pages.d.ts +1 -1
  180. package/dist/internal/prerendered-pages.d.ts.map +1 -1
  181. package/dist/internal/prerendered-pages.js +7 -4
  182. package/dist/internal/prerendered-pages.js.map +1 -1
  183. package/dist/internal/query-digest.d.ts +9 -0
  184. package/dist/internal/query-digest.d.ts.map +1 -0
  185. package/dist/internal/query-digest.js +23 -0
  186. package/dist/internal/query-digest.js.map +1 -0
  187. package/dist/internal/query-runtime.d.ts +6 -1
  188. package/dist/internal/query-runtime.d.ts.map +1 -1
  189. package/dist/internal/query-runtime.js +9 -0
  190. package/dist/internal/query-runtime.js.map +1 -1
  191. package/dist/internal/query-seam.d.ts +29 -0
  192. package/dist/internal/query-seam.d.ts.map +1 -0
  193. package/dist/internal/query-seam.js +23 -0
  194. package/dist/internal/query-seam.js.map +1 -0
  195. package/dist/internal/render-document.d.ts +28 -1
  196. package/dist/internal/render-document.d.ts.map +1 -1
  197. package/dist/internal/render-document.js +83 -18
  198. package/dist/internal/render-document.js.map +1 -1
  199. package/dist/internal/request-abort.d.ts +8 -0
  200. package/dist/internal/request-abort.d.ts.map +1 -0
  201. package/dist/internal/request-abort.js +22 -0
  202. package/dist/internal/request-abort.js.map +1 -0
  203. package/dist/internal/server-boundary.d.ts +2 -0
  204. package/dist/internal/server-boundary.d.ts.map +1 -1
  205. package/dist/internal/server-boundary.js +8 -1
  206. package/dist/internal/server-boundary.js.map +1 -1
  207. package/dist/internal/share-data.d.ts +10 -0
  208. package/dist/internal/share-data.d.ts.map +1 -0
  209. package/dist/internal/share-data.js +135 -0
  210. package/dist/internal/share-data.js.map +1 -0
  211. package/dist/internal/store-signature.d.ts +15 -0
  212. package/dist/internal/store-signature.d.ts.map +1 -0
  213. package/dist/internal/store-signature.js +114 -0
  214. package/dist/internal/store-signature.js.map +1 -0
  215. package/dist/internal/vite-plugin-compat.d.ts +2 -2
  216. package/dist/internal/vite-plugin-compat.js +2 -2
  217. package/dist/internal/web-app.d.ts +25 -0
  218. package/dist/internal/web-app.d.ts.map +1 -1
  219. package/dist/internal/web-app.js +78 -3
  220. package/dist/internal/web-app.js.map +1 -1
  221. package/dist/islands-swap.d.ts +41 -0
  222. package/dist/islands-swap.d.ts.map +1 -0
  223. package/dist/islands-swap.js +270 -0
  224. package/dist/islands-swap.js.map +1 -0
  225. package/dist/islands.d.ts +86 -24
  226. package/dist/islands.d.ts.map +1 -1
  227. package/dist/islands.js +138 -78
  228. package/dist/islands.js.map +1 -1
  229. package/dist/isr.d.ts +67 -13
  230. package/dist/isr.d.ts.map +1 -1
  231. package/dist/isr.js +247 -99
  232. package/dist/isr.js.map +1 -1
  233. package/dist/keep-alive.d.ts +147 -0
  234. package/dist/keep-alive.d.ts.map +1 -0
  235. package/dist/keep-alive.js +1069 -0
  236. package/dist/keep-alive.js.map +1 -0
  237. package/dist/live/bus.d.ts +56 -0
  238. package/dist/live/bus.d.ts.map +1 -0
  239. package/dist/live/bus.js +32 -0
  240. package/dist/live/bus.js.map +1 -0
  241. package/dist/live/core.d.ts +25 -0
  242. package/dist/live/core.d.ts.map +1 -0
  243. package/dist/live/core.js +29 -0
  244. package/dist/live/core.js.map +1 -0
  245. package/dist/live/history.d.ts +38 -0
  246. package/dist/live/history.d.ts.map +1 -0
  247. package/dist/live/history.js +104 -0
  248. package/dist/live/history.js.map +1 -0
  249. package/dist/live/hub-protocol.d.ts +46 -0
  250. package/dist/live/hub-protocol.d.ts.map +1 -0
  251. package/dist/live/hub-protocol.js +108 -0
  252. package/dist/live/hub-protocol.js.map +1 -0
  253. package/dist/live/hub.d.ts +181 -0
  254. package/dist/live/hub.d.ts.map +1 -0
  255. package/dist/live/hub.js +539 -0
  256. package/dist/live/hub.js.map +1 -0
  257. package/dist/live/keyring.d.ts +87 -0
  258. package/dist/live/keyring.d.ts.map +1 -0
  259. package/dist/live/keyring.js +260 -0
  260. package/dist/live/keyring.js.map +1 -0
  261. package/dist/live/read-capped.d.ts +6 -0
  262. package/dist/live/read-capped.d.ts.map +1 -0
  263. package/dist/live/read-capped.js +42 -0
  264. package/dist/live/read-capped.js.map +1 -0
  265. package/dist/live/revocations.d.ts +52 -0
  266. package/dist/live/revocations.d.ts.map +1 -0
  267. package/dist/live/revocations.js +116 -0
  268. package/dist/live/revocations.js.map +1 -0
  269. package/dist/live/settings.d.ts +14 -0
  270. package/dist/live/settings.d.ts.map +1 -0
  271. package/dist/live/settings.js +38 -0
  272. package/dist/live/settings.js.map +1 -0
  273. package/dist/live-client.d.ts +34 -0
  274. package/dist/live-client.d.ts.map +1 -0
  275. package/dist/live-client.js +840 -0
  276. package/dist/live-client.js.map +1 -0
  277. package/dist/live-hub.d.ts +130 -0
  278. package/dist/live-hub.d.ts.map +1 -0
  279. package/dist/live-hub.js +507 -0
  280. package/dist/live-hub.js.map +1 -0
  281. package/dist/live-redis.d.ts +48 -0
  282. package/dist/live-redis.d.ts.map +1 -0
  283. package/dist/live-redis.js +201 -0
  284. package/dist/live-redis.js.map +1 -0
  285. package/dist/live.d.ts +94 -0
  286. package/dist/live.d.ts.map +1 -0
  287. package/dist/live.js +869 -0
  288. package/dist/live.js.map +1 -0
  289. package/dist/manifest.d.ts +113 -2
  290. package/dist/manifest.d.ts.map +1 -1
  291. package/dist/manifest.js +109 -5
  292. package/dist/manifest.js.map +1 -1
  293. package/dist/navigation.d.ts +9 -3
  294. package/dist/navigation.d.ts.map +1 -1
  295. package/dist/navigation.js +13 -3
  296. package/dist/navigation.js.map +1 -1
  297. package/dist/plugins/vite-zone-guard.d.ts +6 -0
  298. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  299. package/dist/plugins/vite-zone-guard.js +8 -2
  300. package/dist/plugins/vite-zone-guard.js.map +1 -1
  301. package/dist/ppr.d.ts +73 -0
  302. package/dist/ppr.d.ts.map +1 -0
  303. package/dist/ppr.js +76 -0
  304. package/dist/ppr.js.map +1 -0
  305. package/dist/query.d.ts +8 -13
  306. package/dist/query.d.ts.map +1 -1
  307. package/dist/query.js +112 -18
  308. package/dist/query.js.map +1 -1
  309. package/dist/render-seam.d.ts +30 -0
  310. package/dist/render-seam.d.ts.map +1 -1
  311. package/dist/render-seam.js +5 -0
  312. package/dist/render-seam.js.map +1 -1
  313. package/dist/route-types.d.ts.map +1 -1
  314. package/dist/route-types.js +2 -2
  315. package/dist/route-types.js.map +1 -1
  316. package/dist/router.d.ts +72 -4
  317. package/dist/router.d.ts.map +1 -1
  318. package/dist/router.js +374 -99
  319. package/dist/router.js.map +1 -1
  320. package/dist/server-components/build.d.ts.map +1 -1
  321. package/dist/server-components/build.js +3 -3
  322. package/dist/server-components/build.js.map +1 -1
  323. package/dist/server-components/client.d.ts +23 -1
  324. package/dist/server-components/client.d.ts.map +1 -1
  325. package/dist/server-components/client.js +92 -5
  326. package/dist/server-components/client.js.map +1 -1
  327. package/dist/server-components/hole.d.ts +8 -0
  328. package/dist/server-components/hole.d.ts.map +1 -0
  329. package/dist/server-components/hole.js +20 -0
  330. package/dist/server-components/hole.js.map +1 -0
  331. package/dist/server-components/html-policy.d.ts.map +1 -1
  332. package/dist/server-components/html-policy.js +62 -4
  333. package/dist/server-components/html-policy.js.map +1 -1
  334. package/dist/server-components/html-scan.d.ts +17 -0
  335. package/dist/server-components/html-scan.d.ts.map +1 -0
  336. package/dist/server-components/html-scan.js +180 -0
  337. package/dist/server-components/html-scan.js.map +1 -0
  338. package/dist/server-components/html.d.ts.map +1 -1
  339. package/dist/server-components/html.js +19 -4
  340. package/dist/server-components/html.js.map +1 -1
  341. package/dist/server-components/router-integration.d.ts +4 -1
  342. package/dist/server-components/router-integration.d.ts.map +1 -1
  343. package/dist/server-components/router-integration.js +23 -2
  344. package/dist/server-components/router-integration.js.map +1 -1
  345. package/dist/server-components/runtime.d.ts +6 -1
  346. package/dist/server-components/runtime.d.ts.map +1 -1
  347. package/dist/server-components/runtime.js +86 -28
  348. package/dist/server-components/runtime.js.map +1 -1
  349. package/dist/server-components/state.d.ts +3 -1
  350. package/dist/server-components/state.d.ts.map +1 -1
  351. package/dist/server-components/state.js +50 -38
  352. package/dist/server-components/state.js.map +1 -1
  353. package/dist/server-components/types.d.ts +9 -0
  354. package/dist/server-components/types.d.ts.map +1 -1
  355. package/dist/vite.d.ts +5 -1
  356. package/dist/vite.d.ts.map +1 -1
  357. package/dist/vite.js +58 -4
  358. package/dist/vite.js.map +1 -1
  359. package/package.json +48 -8
  360. package/src/boundary.ts +77 -5
  361. package/src/build-plan.ts +3 -0
  362. package/src/build-vite.ts +2 -0
  363. package/src/build.ts +19 -1
  364. package/src/client.ts +147 -10
  365. package/src/csp.ts +16 -2
  366. package/src/deferred.ts +11 -5
  367. package/src/dev-feed.ts +11 -2
  368. package/src/dev-session.ts +22 -11
  369. package/src/dev.ts +29 -8
  370. package/src/diagnostic-catalog.ts +53 -0
  371. package/src/diagnostic.ts +1 -0
  372. package/src/fn.ts +24 -7
  373. package/src/fs.ts +22 -8
  374. package/src/identity.ts +61 -0
  375. package/src/index.ts +19 -0
  376. package/src/internal/cdn/cloudflare.ts +2 -1
  377. package/src/internal/certified-components.ts +1 -1
  378. package/src/internal/codegen.ts +87 -8
  379. package/src/internal/dev-websocket.ts +230 -0
  380. package/src/internal/hole-runtime.ts +286 -0
  381. package/src/internal/identity-epoch.ts +235 -0
  382. package/src/internal/invalidation-contract.ts +35 -0
  383. package/src/internal/invalidation.ts +213 -0
  384. package/src/internal/island-bindings.ts +191 -0
  385. package/src/internal/island-list.ts +92 -0
  386. package/src/internal/island-mount.ts +68 -0
  387. package/src/internal/island-owner.ts +20 -0
  388. package/src/internal/island-resource.ts +76 -0
  389. package/src/internal/island-signals.ts +249 -0
  390. package/src/internal/keep-alive-table.ts +72 -0
  391. package/src/internal/live-contract.ts +106 -0
  392. package/src/internal/live-queries.ts +60 -0
  393. package/src/internal/page-events.ts +27 -0
  394. package/src/internal/page-execution.ts +579 -40
  395. package/src/internal/ppr/admission.ts +300 -0
  396. package/src/internal/ppr/canary.ts +122 -0
  397. package/src/internal/ppr/compile-shell.ts +379 -0
  398. package/src/internal/ppr/contract.ts +265 -0
  399. package/src/internal/ppr/frames.ts +170 -0
  400. package/src/internal/ppr/integration.ts +137 -0
  401. package/src/internal/ppr/options.ts +221 -0
  402. package/src/internal/ppr/route-rules.ts +77 -0
  403. package/src/internal/ppr/runtime.ts +36 -0
  404. package/src/internal/ppr/server.ts +1764 -0
  405. package/src/internal/ppr/shell-store.ts +139 -0
  406. package/src/internal/prerendered-pages.ts +7 -4
  407. package/src/internal/query-digest.ts +34 -0
  408. package/src/internal/query-runtime.ts +14 -1
  409. package/src/internal/query-seam.ts +48 -0
  410. package/src/internal/render-document.ts +149 -22
  411. package/src/internal/request-abort.ts +26 -0
  412. package/src/internal/server-boundary.ts +10 -1
  413. package/src/internal/share-data.ts +136 -0
  414. package/src/internal/store-signature.ts +149 -0
  415. package/src/internal/vite-plugin-compat.ts +2 -2
  416. package/src/internal/web-app.ts +121 -3
  417. package/src/islands-swap.ts +283 -0
  418. package/src/islands.ts +169 -72
  419. package/src/isr.ts +348 -106
  420. package/src/keep-alive.ts +1315 -0
  421. package/src/live/bus.ts +77 -0
  422. package/src/live/core.ts +52 -0
  423. package/src/live/history.ts +109 -0
  424. package/src/live/hub-protocol.ts +133 -0
  425. package/src/live/hub.ts +615 -0
  426. package/src/live/keyring.ts +379 -0
  427. package/src/live/read-capped.ts +36 -0
  428. package/src/live/revocations.ts +155 -0
  429. package/src/live/settings.ts +46 -0
  430. package/src/live-client.ts +973 -0
  431. package/src/live-hub.ts +658 -0
  432. package/src/live-redis.ts +259 -0
  433. package/src/live.ts +1096 -0
  434. package/src/manifest.ts +274 -6
  435. package/src/navigation.ts +14 -2
  436. package/src/plugins/vite-zone-guard.ts +15 -2
  437. package/src/ppr.ts +153 -0
  438. package/src/query.ts +147 -20
  439. package/src/render-seam.ts +35 -0
  440. package/src/route-types.ts +3 -1
  441. package/src/router.ts +494 -101
  442. package/src/server-components/build.ts +9 -3
  443. package/src/server-components/client.ts +89 -5
  444. package/src/server-components/hole.ts +23 -0
  445. package/src/server-components/html-policy.ts +63 -4
  446. package/src/server-components/html-scan.ts +159 -0
  447. package/src/server-components/html.ts +18 -4
  448. package/src/server-components/router-integration.ts +21 -2
  449. package/src/server-components/runtime.ts +112 -30
  450. package/src/server-components/state.ts +66 -44
  451. package/src/server-components/types.ts +16 -0
  452. package/src/vite.ts +75 -6
  453. package/dist/nano.d.ts +0 -125
  454. package/dist/nano.d.ts.map +0 -1
  455. package/dist/nano.js +0 -190
  456. package/dist/nano.js.map +0 -1
  457. package/src/nano.ts +0 -246
package/src/router.ts CHANGED
@@ -7,10 +7,12 @@
7
7
  import { decodeRouteParams } from "@nifrajs/core/pattern"
8
8
  import { Router as CoreRouter } from "@nifrajs/core/router"
9
9
  import type { StandardSchemaV1 } from "@nifrajs/core/server"
10
- import type { BoundaryDescriptor, BoundaryStates } from "./boundary.ts"
10
+ import type { BoundaryDescriptor, BoundaryState, BoundaryStates } from "./boundary.ts"
11
11
  import { parseNdjsonData } from "./deferred.ts"
12
+ import { epochOf, IDENTITY_CHANNEL, onIdentityChange } from "./identity.ts"
12
13
  import { parsePageData } from "./internal/revive-dates.ts"
13
14
  import { appUrl, requestPattern } from "./internal/route-path.ts"
15
+ import { shareData } from "./internal/share-data.ts"
14
16
  import type { ClientActionResult, ClientRequestBody, ClientRouteHooks } from "./manifest.ts"
15
17
  import type { MatchChain } from "./render-seam.ts"
16
18
  import { isClientOnlySearchChange } from "./search.ts"
@@ -21,6 +23,12 @@ import { isClientOnlySearchChange } from "./search.ts"
21
23
  */
22
24
  export const DATA_HEADER = "x-nifra-data"
23
25
 
26
+ /**
27
+ * Sent beside {@link DATA_HEADER} by a prefetch. A partially prerendered route answers it with its
28
+ * shell data and starts no hole: nothing could cancel that work if the link is never followed.
29
+ */
30
+ export const PREFETCH_HEADER = "x-nifra-prefetch"
31
+
24
32
  /**
25
33
  * Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
26
34
  * The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
@@ -84,6 +92,20 @@ export const RETAIN_HEADER = "x-nifra-retain"
84
92
  */
85
93
  export const NAV_FROM_HEADER = "x-nifra-from"
86
94
 
95
+ /**
96
+ * Sent on a data request that reloads the route instance on screen: the holes, comma separated, whose
97
+ * hosts the partially prerendered page holds. Each one's state then carries its fill (server HTML,
98
+ * plus props when it hydrates) instead of its data, so the client updates the host in place.
99
+ */
100
+ export const HOLE_FILL_HEADER = "x-nifra-hole-fill"
101
+
102
+ /**
103
+ * Holes of a partially prerendered page the client keeps, comma separated: they are not loaded, and
104
+ * each state says so. Only the live load endpoint sets it, for holes whose keys did not change. A name
105
+ * the route does not declare is refused with a 400.
106
+ */
107
+ export const KEEP_HOLES_HEADER = "x-nifra-keep-holes"
108
+
87
109
  /**
88
110
  * The data-mode payload once a chain can carry layout data.
89
111
  *
@@ -105,6 +127,8 @@ export interface RouteDataEnvelope {
105
127
  /** Terminal status signalled by a loader. Added by the browser transport after reading
106
128
  * `X-Nifra-Status`; old servers and static data files simply omit it. */
107
129
  readonly status?: number
130
+ /** A live load's handle, carried through to {@link RouterState.live} untouched. */
131
+ readonly live?: unknown
108
132
  }
109
133
 
110
134
  /** Recognise the envelope without mistaking a plain loader object that happens to have a `data` key. */
@@ -136,7 +160,8 @@ export interface RouterState {
136
160
  readonly serverComponents?: import("./server-components/types.ts").ComponentState | undefined
137
161
  readonly routeId: string
138
162
  readonly params: Record<string, string>
139
- /** The current URL path (used to revalidate the active loader after an action). */
163
+ /** The current URL path (used to revalidate the active loader after an action). While
164
+ * {@link intercepted} is set it is the target shown in the boundary, not the page's own. */
140
165
  readonly path: string
141
166
  readonly data: unknown
142
167
  /** Per-layout loader data, aligned with the matched chain's leading layout prefix. Absent when no
@@ -154,6 +179,33 @@ export interface RouterState {
154
179
  readonly pendingPath?: string | undefined
155
180
  /** The in-flight submit (set during a `submit`, cleared when it settles) - for optimistic UI. */
156
181
  readonly submission?: Submission
182
+ /** Set while an intercept boundary shows a navigation's target over the page: the boundary holding
183
+ * it, and the path the page underneath was loaded from. The page revalidates under `pagePath`, the
184
+ * boundary under `path`. */
185
+ readonly intercepted?: Intercepted
186
+ /** Opaque: which live load the page data on screen came from (`@nifrajs/web/live-client`). */
187
+ readonly live?: unknown
188
+ /** The page instance on screen, when the app keeps pages alive (`@nifrajs/web/keep-alive`). */
189
+ readonly instance?: string | undefined
190
+ /** Pages kept mounted and hidden, least recently shown first. Absent when none is kept. */
191
+ readonly kept?: readonly KeptPage[] | undefined
192
+ /** This publish showed a kept page again. */
193
+ readonly restored?: boolean | undefined
194
+ /** A page shown again is reloading its data in place: not a navigation, so `pending` stays false. */
195
+ readonly revalidating?: boolean | undefined
196
+ }
197
+
198
+ /** A page kept mounted while its history entry is not the one on screen. */
199
+ export interface KeptPage {
200
+ readonly instance: string
201
+ /** Its last settled state, which it renders from while hidden. */
202
+ readonly state: RouterState
203
+ }
204
+
205
+ /** Which intercept boundary is open, and the page underneath it. */
206
+ export interface Intercepted {
207
+ readonly boundary: string
208
+ readonly pagePath: string
157
209
  }
158
210
 
159
211
  /** A route id paired with its nifra pattern (e.g. `":id"` segments) - the matcher input. */
@@ -194,8 +246,22 @@ export function createMatcher(
194
246
  }
195
247
  }
196
248
 
249
+ /**
250
+ * What the router knows about the response text a path's data on screen was read from. The router's
251
+ * own transport resolves a reload answering that same text to this object itself, and the router keeps
252
+ * the data on screen without parsing it; for any other plain JSON response it records the text in
253
+ * `text`. A response with deferred parts sets neither, and a custom transport may ignore it.
254
+ */
255
+ export interface DataText {
256
+ readonly previous: string | undefined
257
+ text?: string
258
+ }
259
+
197
260
  /** How a router fetches a route's loader data on navigation. `signal` aborts a superseded fetch
198
- * (and its deferred stream). */
261
+ * (and its deferred stream). `purpose` is `"prefetch"` when no navigation is waiting on the result.
262
+ * `hosts` names the holes of the page on screen whose hosts reload in place
263
+ * ({@link HOLE_FILL_HEADER}). `read` is the {@link DataText} of the data on screen, which a custom
264
+ * transport may ignore. */
199
265
  export type FetchRouteData = (
200
266
  path: string,
201
267
  match: RouteMatch,
@@ -204,6 +270,9 @@ export type FetchRouteData = (
204
270
  readonly from: string
205
271
  readonly retain: readonly number[]
206
272
  },
273
+ purpose?: "prefetch",
274
+ hosts?: readonly string[],
275
+ read?: DataText,
207
276
  ) => Promise<unknown>
208
277
 
209
278
  /** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
@@ -265,7 +334,8 @@ export interface ClientRouter {
265
334
  * the action already returned everything that changed, saving the extra round-trip). A redirect
266
335
  * always loads its target regardless. Rejects on failure (caller falls back); once the server has
267
336
  * answered, at any status, the rejection's `redirectTo` names the page to load instead of posting
268
- * again.
337
+ * again. A revalidation answered with a terminal status renders that status route in the page's
338
+ * place.
269
339
  */
270
340
  submit: (
271
341
  action: string,
@@ -280,6 +350,8 @@ export interface ClientRouter {
280
350
  * refreshes immediately - refetched + republished - whenever it's in scope (an explicit list that
281
351
  * includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
282
352
  * fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
353
+ * A refetch answered with a terminal status renders that status route in the page's place; one
354
+ * whose status page renders inside layouts rejects, so the caller can load the page as a document.
283
355
  * The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
284
356
  */
285
357
  invalidate: (paths?: readonly string[]) => Promise<void>
@@ -354,10 +426,19 @@ export interface MountRouterOptions {
354
426
  /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
355
427
  * `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
356
428
  * navigation fetches and action submits - both transports are identical. */
357
- const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown> =>
429
+ const readResponseData = (
430
+ res: Response,
431
+ signal?: AbortSignal,
432
+ read?: DataText,
433
+ ): Promise<unknown> =>
358
434
  (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
359
435
  ? parseNdjsonData(res.body, signal)
360
- : res.text().then(parsePageData)
436
+ : res.text().then((text) => {
437
+ if (read === undefined) return parsePageData(text)
438
+ if (text === read.previous) return read
439
+ read.text = text
440
+ return parsePageData(text)
441
+ })
361
442
 
362
443
  // Index scan, not `/\/+$/` - the unanchored-start trailing-run replace backtracks quadratically and
363
444
  // the pathname is caller-controlled.
@@ -418,7 +499,36 @@ const targetUrl = (to: string): URL | undefined => {
418
499
  const redirected = (to: string, cause?: unknown): Error =>
419
500
  Object.assign(new Error(`[nifra/web] redirected to ${to}`, { cause }), { redirectTo: to })
420
501
 
421
- const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
502
+ /**
503
+ * A route data response as `fetchData` resolves it: a loader's in-app redirect rejects with its
504
+ * target, a terminal status becomes an envelope carrying it, and any other failure rejects.
505
+ */
506
+ export const routeDataOf = async (
507
+ res: Response,
508
+ path: string,
509
+ signal?: AbortSignal,
510
+ read?: DataText,
511
+ ): Promise<unknown> => {
512
+ const redirectTo = res.headers.get(REDIRECT_HEADER)
513
+ if (redirectTo !== null) throw redirected(redirectTo)
514
+ const status = Number(res.headers.get(STATUS_HEADER))
515
+ if (Number.isInteger(status) && status >= 400 && status <= 599) {
516
+ return { v: 1, data: null, status } satisfies RouteDataEnvelope
517
+ }
518
+ if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
519
+ return readResponseData(res, signal, read)
520
+ }
521
+
522
+ /** The router's own transport, which a `fetchData` override may layer over. */
523
+ export const fetchRouteData: FetchRouteData = async (
524
+ path,
525
+ _match,
526
+ signal,
527
+ navigation,
528
+ purpose,
529
+ hosts,
530
+ read,
531
+ ) => {
422
532
  // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
423
533
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
424
534
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
@@ -432,23 +542,22 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
432
542
  const pathname = hash === -1 ? path : path.slice(0, hash)
433
543
  if (prerenderedSetOf(prerendered).has(pathname)) {
434
544
  const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null })
435
- if (staticRes.ok) return readResponseData(staticRes, signal)
545
+ if (staticRes.ok) return readResponseData(staticRes, signal, read)
436
546
  }
437
547
  }
438
548
  const headers = new Headers({ [DATA_HEADER]: "1" })
549
+ if (purpose === "prefetch") headers.set(PREFETCH_HEADER, "1")
550
+ if (hosts !== undefined && hosts.length > 0) headers.set(HOLE_FILL_HEADER, hosts.join(","))
439
551
  if (navigation !== undefined && navigation.retain.length > 0) {
440
552
  headers.set(RETAIN_HEADER, navigation.retain.join(","))
441
553
  headers.set(NAV_FROM_HEADER, navigation.from)
442
554
  }
443
- const res = await fetch(appUrl(path), { headers, signal: signal ?? null })
444
- const redirectTo = res.headers.get(REDIRECT_HEADER)
445
- if (redirectTo !== null) throw redirected(redirectTo)
446
- const status = Number(res.headers.get(STATUS_HEADER))
447
- if (Number.isInteger(status) && status >= 400 && status <= 599) {
448
- return { v: 1, data: null, status } satisfies RouteDataEnvelope
449
- }
450
- if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
451
- return readResponseData(res, signal)
555
+ return routeDataOf(
556
+ await fetch(appUrl(path), { headers, signal: signal ?? null }),
557
+ path,
558
+ signal,
559
+ read,
560
+ )
452
561
  }
453
562
 
454
563
  /**
@@ -474,9 +583,9 @@ const isClientActionResult = (value: unknown): value is ClientActionResult =>
474
583
 
475
584
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
476
585
  const match = createMatcher(options.patterns)
477
- const fetchData = options.fetchData ?? defaultFetchData
586
+ const fetchData = options.fetchData ?? fetchRouteData
478
587
  // Start a handed-over list's fetch now, so the first navigation rarely waits for it.
479
- if (fetchData === defaultFetchData) prerenderedOf()
588
+ if (fetchData === fetchRouteData) prerenderedOf()
480
589
  // routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
481
590
  // to it as routes load), so a same-route nav always sees the current route's keys.
482
591
  const searchClientKeys = options.searchClientKeys ?? {}
@@ -535,31 +644,96 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
535
644
  readonly layoutData?: readonly unknown[] | undefined
536
645
  readonly terminalRouteId?: string | undefined
537
646
  readonly boundaries?: BoundaryStates
647
+ readonly live?: unknown
648
+ /** The response text this was read from, when it was plain JSON. */
649
+ readonly text?: string | undefined
538
650
  }
539
651
  const dataFields = (value: LoadedRouteData) => ({
540
652
  data: value.data,
541
653
  layoutData: value.layoutData,
542
654
  serverComponents: value.serverComponents,
655
+ live: value.live,
543
656
  ...(value.boundaries === undefined ? {} : { boundaries: value.boundaries }),
544
657
  })
658
+ // The path the page on screen was loaded from. While a boundary holds an intercepted target, `path`
659
+ // is that target's URL, so anything that reloads or caches the page must read this instead.
660
+ const pagePathOf = (current: RouterState): string => current.intercepted?.pagePath ?? current.path
661
+ // `boundaries` with `name` holding a target's outcome. The URL names one target, so every other
662
+ // intercept boundary goes back to unresolved. A target that answered with a status, or failed, is
663
+ // an error state: its data never reaches the boundary.
664
+ const holdTarget = (
665
+ boundaries: BoundaryStates | undefined,
666
+ descriptor: Omit<BoundaryDescriptor, "hasLoad">,
667
+ outcome: LoadedRouteData | undefined,
668
+ ): BoundaryStates => {
669
+ const next: Record<string, BoundaryState> = {}
670
+ for (const [name, boundary] of Object.entries(boundaries ?? {})) {
671
+ next[name] =
672
+ typeof boundary.mode === "string" || name === descriptor.name
673
+ ? boundary
674
+ : {
675
+ name: boundary.name,
676
+ mode: boundary.mode,
677
+ status: "unresolved",
678
+ ...(boundary.errorId === undefined ? {} : { errorId: boundary.errorId }),
679
+ }
680
+ }
681
+ const base = {
682
+ name: descriptor.name,
683
+ mode: descriptor.mode,
684
+ ...(descriptor.errorId === undefined ? {} : { errorId: descriptor.errorId }),
685
+ }
686
+ next[descriptor.name] =
687
+ outcome === undefined || outcome.terminalRouteId !== undefined
688
+ ? { ...base, status: "error", error: { name: "Error", message: "Boundary failed" } }
689
+ : { ...base, status: "ready", data: outcome.data, live: outcome.live }
690
+ return next
691
+ }
692
+ // `boundaries` with `name`'s action result set (or cleared, for `undefined`). Only a boundary showing
693
+ // its target's data carries one.
694
+ const withActionData = (
695
+ boundaries: BoundaryStates | undefined,
696
+ name: string,
697
+ actionData: unknown,
698
+ ): BoundaryStates | undefined => {
699
+ const held = boundaries?.[name]
700
+ if (held === undefined || held.status !== "ready") return boundaries
701
+ const { actionData: _previous, ...rest } = held
702
+ return { ...boundaries, [name]: actionData === undefined ? rest : { ...rest, actionData } }
703
+ }
545
704
  const loadRouteData = async (
546
705
  path: string,
547
706
  match: { routeId: string; params: Record<string, string> },
548
707
  signal?: AbortSignal,
549
708
  // Offer the server the layout data on screen: it re-runs whichever of those loaders it must.
550
709
  keepLayouts?: boolean,
710
+ purpose?: "prefetch",
711
+ hosts?: readonly string[],
712
+ // What this path shows, when this is a reload of it: the same response text again is that, unparsed.
713
+ shown?: LoadedRouteData,
551
714
  ): Promise<LoadedRouteData> => {
552
715
  const retainFrom = keepLayouts
553
- ? state.layoutData && { path: state.path, layoutData: state.layoutData }
716
+ ? state.layoutData && { path: pagePathOf(state), layoutData: state.layoutData }
554
717
  : undefined
555
718
  const retain = retainFrom?.layoutData.map((_, index) => index) ?? []
719
+ const hooks = routeHooks[match.routeId]
720
+ // A client loader's output is not what the text says, and an opted-out route takes fresh data.
721
+ const read: DataText = {
722
+ previous:
723
+ hooks?.clientLoader === undefined && hooks?.shareData !== false ? shown?.text : undefined,
724
+ }
556
725
  const payload = await fetchData(
557
726
  path,
558
727
  match,
559
728
  signal,
560
729
  retainFrom === undefined ? undefined : { from: retainFrom.path, retain },
730
+ purpose,
731
+ hosts,
732
+ read,
561
733
  )
562
- if (!isRouteDataEnvelope(payload)) return { data: payload }
734
+ if (shown !== undefined && payload === read) return shown
735
+ const text = read.text
736
+ if (!isRouteDataEnvelope(payload)) return { data: payload, text }
563
737
  if (payload.status !== undefined) {
564
738
  const terminalRouteId = options.statusRoutes?.[payload.status] ?? options.statusRoutes?.[404]
565
739
  if (terminalRouteId === undefined) {
@@ -584,7 +758,31 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
584
758
  data: payload.data,
585
759
  layoutData,
586
760
  serverComponents: payload.serverComponents,
761
+ live: payload.live,
587
762
  ...(payload.boundaries !== undefined ? { boundaries: payload.boundaries } : {}),
763
+ text,
764
+ }
765
+ }
766
+ // A reload with every part equal to what its path shows replaced by that part, so what the reload
767
+ // did not change keeps its identity on screen (`shareData`), unless the route exports
768
+ // `shareData = false`.
769
+ const shareShown = (
770
+ routeId: string,
771
+ shown: LoadedRouteData,
772
+ loaded: LoadedRouteData,
773
+ ): LoadedRouteData => {
774
+ if (loaded.terminalRouteId !== undefined || routeHooks[routeId]?.shareData === false)
775
+ return loaded
776
+ // Parsed JSON is a tree; a client loader/custom transport can return cyclic or aliased data.
777
+ const plainJson = loaded.text !== undefined && routeHooks[routeId]?.clientLoader === undefined
778
+ return {
779
+ ...loaded,
780
+ data: shareData(shown.data, loaded.data, plainJson),
781
+ layoutData: shareData(shown.layoutData, loaded.layoutData, plainJson),
782
+ serverComponents: shareData(shown.serverComponents, loaded.serverComponents, plainJson),
783
+ ...(loaded.boundaries === undefined
784
+ ? {}
785
+ : { boundaries: shareData(shown.boundaries, loaded.boundaries, plainJson) }),
588
786
  }
589
787
  }
590
788
  /**
@@ -638,7 +836,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
638
836
  params: target.params,
639
837
  signal,
640
838
  body,
641
- serverLoader: async () => state.data,
839
+ // An action posted to the target an intercept boundary holds belongs to that target.
840
+ serverLoader: async () =>
841
+ state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
842
+ ? state.boundaries?.[state.intercepted.boundary]?.data
843
+ : state.data,
642
844
  })
643
845
  if (result === undefined) return { body }
644
846
  if (!isClientActionResult(result)) {
@@ -668,18 +870,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
668
870
  initial,
669
871
  )
670
872
  if (mine !== generation) return
671
- if (loaded.terminalRouteId !== undefined) {
672
- await loadModule?.(loaded.terminalRouteId, state.path)
673
- state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null }
674
- } else {
675
- state = {
676
- ...state,
677
- ...dataFields(loaded),
678
- }
679
- }
873
+ // No terminal status can arrive here: the server already rendered this route (a status page
874
+ // included) and its data is the only server result the client loader sees.
875
+ state = { ...state, ...dataFields(loaded) }
680
876
  emit()
681
877
  }
682
878
  let state = options.initial
879
+ // The page as last published from a load, with the response text it was read from. Every publish of
880
+ // page data replaces it, and a reload reuses it only for the same text, which says the same.
881
+ let shownPage: LoadedRouteData | undefined
683
882
  const listeners = new Set<() => void>()
684
883
  let generation = 0
685
884
  // Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
@@ -694,12 +893,33 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
694
893
  // fresh: a link warmed as it scrolled into view can be clicked minutes later.
695
894
  const MAX_PREFETCH = 10
696
895
  const PREFETCH_MAX_AGE_MS = 30_000
697
- const prefetched = new Map<string, readonly [LoadedRouteData, number]>()
698
- const inflight = new Set<string>()
896
+ // path -> [data, when it arrived, the identity epoch it was loaded under]
897
+ const prefetched = new Map<string, readonly [LoadedRouteData, number, string]>()
898
+ // path -> the token of the prefetch loading it; a prefetch stores what it read only while its
899
+ // token is still there, so dropping the token discards a load that started before the drop.
900
+ const inflight = new Map<string, object>()
901
+ // Read without the DOM types: this module also builds in tsconfigs that leave the DOM lib out.
902
+ const readEpoch = (): string => {
903
+ const cookie: unknown = Reflect.get(Reflect.get(globalThis, "document") ?? {}, "cookie")
904
+ return typeof cookie === "string" ? epochOf(cookie) : ""
905
+ }
699
906
  const freshPrefetch = (path: string): LoadedRouteData | undefined => {
700
907
  const held = prefetched.get(path)
701
- return held !== undefined && Date.now() - held[1] < PREFETCH_MAX_AGE_MS ? held[0] : undefined
908
+ return held !== undefined &&
909
+ Date.now() - held[1] < PREFETCH_MAX_AGE_MS &&
910
+ held[2] === readEpoch()
911
+ ? held[0]
912
+ : undefined
913
+ }
914
+ // Warmed data was loaded before a mutation or for the previous visitor, so either one drops all of
915
+ // it, along with what a prefetch still in flight read.
916
+ const dropPrefetched = (): void => {
917
+ inflight.clear()
918
+ prefetched.clear()
702
919
  }
920
+ onIdentityChange(dropPrefetched)
921
+ if (typeof BroadcastChannel !== "undefined")
922
+ new BroadcastChannel(IDENTITY_CHANNEL).addEventListener("message", dropPrefetched)
703
923
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
704
924
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
705
925
  // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
@@ -737,6 +957,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
737
957
  // `X-Nifra-Revalidate` handling in `submit`.
738
958
  const markStale = (paths: readonly string[]): void => {
739
959
  for (const p of paths) {
960
+ prefetched.delete(p)
961
+ inflight.delete(p)
740
962
  const entry = cache.get(p)
741
963
  if (entry !== undefined) {
742
964
  cache.set(p, {
@@ -773,6 +995,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
773
995
  return target === null ? undefined : [path, target]
774
996
  }
775
997
 
998
+ // Load the status route a terminal answer for `at` renders. One inside layouts needs those layouts'
999
+ // loaders, which only a document request runs, so the caller loads `at` as a document instead.
1000
+ const loadTerminal = async (routeId: string, at: string): Promise<void> => {
1001
+ await loadModule?.(routeId, at)
1002
+ if (options.layoutStatusRoutes?.[routeId] === true) {
1003
+ throw new Error(`[nifra/web] ${routeId} renders inside layouts; loading ${at} as a document`)
1004
+ }
1005
+ }
1006
+
776
1007
  // Load `path` for the navigation or submit `mine`, following the redirects its loaders answer with
777
1008
  // while they stay in the app (20 at most, as a browser allows).
778
1009
  const loadFollowing = async (
@@ -802,14 +1033,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
802
1033
  () => loadRouteData(at, route, signal, keepLayouts),
803
1034
  prefetchedData,
804
1035
  )
805
- if (loaded.terminalRouteId !== undefined) {
806
- await loadModule?.(loaded.terminalRouteId, at)
807
- if (options.layoutStatusRoutes?.[loaded.terminalRouteId] === true) {
808
- throw new Error(
809
- `[nifra/web] ${loaded.terminalRouteId} renders inside layouts; loading ${at} as a document`,
810
- )
811
- }
812
- }
1036
+ if (loaded.terminalRouteId !== undefined) await loadTerminal(loaded.terminalRouteId, at)
813
1037
  return [at, route, loaded]
814
1038
  } catch (error) {
815
1039
  const next = hops < 20 ? appRedirect(redirectOf(error)) : undefined
@@ -819,9 +1043,113 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
819
1043
  }
820
1044
  }
821
1045
 
822
- // Refetch the active route + republish, single-flight via the shared `generation`. Shared by `invalidate`
823
- // and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
824
- const refetchActive = async (): Promise<void> => {
1046
+ // Reload what is on screen: the page under its own path when `page`, and, while an intercept boundary
1047
+ // holds a target, that target under its URL when `target`. The part not reloaded is kept as shown.
1048
+ // Returns the fields to publish, both parts together. `route` is set when the page's reload names
1049
+ // another route - the status route it answered with, or, on a status page, its URL's own route
1050
+ // answering again - which then replaces the page and any boundary over it, so data is only ever
1051
+ // rendered by the route it was loaded for. A target that fails becomes its
1052
+ // boundary's error state rather than failing the page, as a dynamic boundary's load does. Nothing is
1053
+ // cached once `mine` is superseded: a newer action's reload owns the cache.
1054
+ const reloadActive = async (
1055
+ signal: AbortSignal,
1056
+ page: boolean,
1057
+ target: boolean,
1058
+ mine: number,
1059
+ ): Promise<{
1060
+ readonly fields: ReturnType<typeof dataFields>
1061
+ readonly route?: { readonly routeId: string; readonly params: Record<string, string> }
1062
+ }> => {
1063
+ const pagePath = pagePathOf(state)
1064
+ const intercepted = state.intercepted
1065
+ const targetPath = state.path
1066
+ // The route the page's URL matches: the one on screen, except on a status page.
1067
+ const pageRoute = match(pagePath) ?? { routeId: state.routeId, params: state.params }
1068
+ const rematched = pageRoute.routeId !== state.routeId
1069
+ const targetRoute = intercepted !== undefined && target ? match(targetPath) : null
1070
+ // The page's own route shares with what the page shows; a status page answered by its URL's route
1071
+ // has nothing of that route on screen.
1072
+ const shown = rematched ? undefined : (shownPage ?? state)
1073
+ const shownTarget = { data: intercepted && state.boundaries?.[intercepted.boundary]?.data }
1074
+ // A hole on screen that is a host stays one: it reloads as a fill.
1075
+ const hosts = rematched
1076
+ ? []
1077
+ : Object.values(state.boundaries ?? {})
1078
+ .filter((held) => held.ppr === true)
1079
+ .map((held) => held.name)
1080
+ const [reloaded, targetOutcome] = await Promise.all([
1081
+ page
1082
+ ? (async (): Promise<LoadedRouteData> => {
1083
+ if (rematched) await loadModule?.(pageRoute.routeId, pagePath)
1084
+ const loaded = await applyClientLoader(pagePath, pageRoute, signal, () =>
1085
+ loadRouteData(
1086
+ pagePath,
1087
+ pageRoute,
1088
+ signal,
1089
+ false,
1090
+ undefined,
1091
+ hosts.length === 0 ? undefined : hosts,
1092
+ shown,
1093
+ ),
1094
+ )
1095
+ return shown === undefined ? loaded : shareShown(pageRoute.routeId, shown, loaded)
1096
+ })()
1097
+ : undefined,
1098
+ targetRoute === null
1099
+ ? undefined
1100
+ : (async (): Promise<LoadedRouteData | undefined> => {
1101
+ try {
1102
+ await loadModule?.(targetRoute.routeId, targetPath)
1103
+ const outcome = shareShown(
1104
+ targetRoute.routeId,
1105
+ shownTarget,
1106
+ await applyClientLoader(targetPath, targetRoute, signal, () =>
1107
+ loadRouteData(targetPath, targetRoute, signal),
1108
+ ),
1109
+ )
1110
+ if (mine === generation) cachePut(targetPath, outcome)
1111
+ return outcome
1112
+ } catch {
1113
+ return undefined
1114
+ }
1115
+ })(),
1116
+ ])
1117
+ if (reloaded?.terminalRouteId !== undefined)
1118
+ await loadTerminal(reloaded.terminalRouteId, pagePath)
1119
+ if (reloaded !== undefined && mine === generation) {
1120
+ cachePut(pagePath, reloaded)
1121
+ shownPage = reloaded
1122
+ }
1123
+ const fields = dataFields(reloaded ?? state)
1124
+ if (reloaded?.terminalRouteId !== undefined)
1125
+ return { fields, route: { routeId: reloaded.terminalRouteId, params: {} } }
1126
+ if (reloaded !== undefined && rematched) return { fields, route: pageRoute }
1127
+ if (intercepted === undefined) return { fields }
1128
+ const held = state.boundaries?.[intercepted.boundary]
1129
+ if (held === undefined) return { fields }
1130
+ if (targetRoute === null)
1131
+ return {
1132
+ fields: {
1133
+ ...fields,
1134
+ boundaries: { ...(fields.boundaries ?? {}), [intercepted.boundary]: held },
1135
+ },
1136
+ }
1137
+ // A reload keeps the result of the last action posted to the target, as the page keeps its own.
1138
+ const boundaries = withActionData(
1139
+ holdTarget(fields.boundaries, held, targetOutcome),
1140
+ intercepted.boundary,
1141
+ held.actionData,
1142
+ )
1143
+ return { fields: { ...fields, ...(boundaries === undefined ? {} : { boundaries }) } }
1144
+ }
1145
+
1146
+ // Refetch what is on screen + republish, single-flight via the shared `generation`. Shared by
1147
+ // `invalidate` and targeted revalidation: `inScope` says which paths to refresh, and a page or held
1148
+ // target outside it is kept. Rejects if the page's fetch fails (clearing its own `pending` first).
1149
+ const refetchActive = async (inScope?: (path: string) => boolean): Promise<void> => {
1150
+ const page = inScope === undefined || inScope(pagePathOf(state))
1151
+ const target = state.intercepted !== undefined && (inScope === undefined || inScope(state.path))
1152
+ if (!page && !target) return
825
1153
  const mine = ++generation
826
1154
  navAbort?.abort()
827
1155
  const ac = new AbortController()
@@ -829,17 +1157,21 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
829
1157
  state = { ...state, pending: true }
830
1158
  emit()
831
1159
  try {
832
- const active = { routeId: state.routeId, params: state.params }
833
- const loaded = await applyClientLoader(state.path, active, ac.signal, () =>
834
- loadRouteData(state.path, active, ac.signal),
835
- )
1160
+ const { fields, route } = await reloadActive(ac.signal, page, target, mine)
836
1161
  if (mine !== generation) return // superseded - drop the stale result
837
- cachePut(state.path, loaded)
838
- state = {
839
- ...state,
840
- ...dataFields(loaded),
841
- pending: false,
842
- }
1162
+ // A terminal answer for the page (the session expired, the record is gone) replaces it, and any
1163
+ // boundary over it, with its status route, as a navigation to this URL would.
1164
+ state =
1165
+ route === undefined
1166
+ ? { ...state, ...fields, pending: false }
1167
+ : {
1168
+ routeId: route.routeId,
1169
+ params: route.params,
1170
+ path: state.path,
1171
+ ...fields,
1172
+ actionData: undefined,
1173
+ pending: false,
1174
+ }
843
1175
  emit()
844
1176
  } catch (err) {
845
1177
  if (mine === generation) {
@@ -874,7 +1206,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
874
1206
  // swallowed - a failed targeted refresh must not fail the mutation that triggered it.
875
1207
  const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
876
1208
  const jobs: Promise<void>[] = []
877
- if (!skipActive && paths.includes(state.path)) jobs.push(refetchActive().catch(() => {}))
1209
+ if (!skipActive) jobs.push(refetchActive((path) => paths.includes(path)).catch(() => {}))
878
1210
  for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(paths))
879
1211
  await Promise.all(jobs)
880
1212
  }
@@ -884,6 +1216,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
884
1216
  let fGeneration = 0
885
1217
  let fAbort: AbortController | undefined
886
1218
  let loadedPath: string | undefined
1219
+ // `data` as loaded, with the response text it was read from.
1220
+ let shown: LoadedRouteData | undefined
887
1221
  const fListeners = new Set<() => void>()
888
1222
  const fEmit = (): void => {
889
1223
  if (fListeners.size > 0) for (const l of [...fListeners]) l() // snapshot for defensiveness
@@ -899,11 +1233,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
899
1233
  fState = { ...fState, pending: true }
900
1234
  fEmit()
901
1235
  try {
902
- const [, loaded] = await Promise.all([
1236
+ // Loading the path again is a reload of what this fetcher shows.
1237
+ const before = path === loadedPath ? shown : undefined
1238
+ const [, fresh] = await Promise.all([
903
1239
  loadModule?.(matched.routeId),
904
- loadRouteData(path, matched, ac.signal),
1240
+ loadRouteData(path, matched, ac.signal, false, undefined, undefined, before),
905
1241
  ])
906
1242
  if (mine !== fGeneration) return // a newer load/submit on THIS fetcher superseded us
1243
+ const loaded = before === undefined ? fresh : shareShown(matched.routeId, before, fresh)
1244
+ shown = loaded
907
1245
  // Record the loaded path only on SUCCESS - a thrown or superseded load must not
908
1246
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
909
1247
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
@@ -950,6 +1288,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
950
1288
  body,
951
1289
  headers: { [DATA_HEADER]: "1" },
952
1290
  })
1291
+ dropPrefetched()
953
1292
  if (!res.ok)
954
1293
  throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
955
1294
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
@@ -995,12 +1334,25 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
995
1334
  navigate: async (path) => {
996
1335
  const matched = match(path)
997
1336
  if (matched === null) return
1337
+ // The target an open boundary already shows (a `#fragment` entry inside it, a link to itself):
1338
+ // reloading it would rebuild the boundary and drop its action result. One in its error state
1339
+ // loads again, so a link can retry it.
1340
+ if (
1341
+ state.intercepted !== undefined &&
1342
+ state.boundaries?.[state.intercepted.boundary]?.status === "ready" &&
1343
+ !state.pending &&
1344
+ path === state.path
1345
+ ) {
1346
+ emit()
1347
+ return
1348
+ }
998
1349
  // Client-only search fast path: same route + same pathname, and every changed search key is
999
1350
  // declared client-only. Publish the new URL (so `useSearch`/`useLocation` update) WITHOUT a data
1000
1351
  // fetch - the loader's inputs did not change. Any loader-affecting key change falls through to the
1001
1352
  // normal match → fetch → publish below, so data is never stale. Abort an in-flight fetch first: a
1002
1353
  // rapid `?tab` after a real nav must not let the older fetch overwrite this synchronous update.
1003
1354
  if (
1355
+ state.intercepted === undefined &&
1004
1356
  matched.routeId === state.routeId &&
1005
1357
  pathnameOf(path) === pathnameOf(state.path) &&
1006
1358
  isClientOnlySearchChange(
@@ -1026,8 +1378,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1026
1378
  const [to, target, loaded] = await loadFollowing(mine, path, matched, ac.signal, true, true)
1027
1379
  if (mine !== generation) return // a newer navigation superseded this one - drop the stale result
1028
1380
  cachePut(to, loaded) // keep the keyed cache coherent with what we publish
1381
+ // The page's own URL is never intercepted: navigating there (Back out of a boundary, a link
1382
+ // to itself) shows the page, even when a boundary's pattern matches it.
1383
+ const pagePath = pagePathOf(state)
1029
1384
  const interception =
1030
- loaded.terminalRouteId === undefined
1385
+ loaded.terminalRouteId === undefined && pathnameOf(to) !== pathnameOf(pagePath)
1031
1386
  ? interceptionFor(state.routeId, to, target)
1032
1387
  : undefined
1033
1388
  if (interception !== undefined) {
@@ -1038,17 +1393,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1038
1393
  state = {
1039
1394
  ...state,
1040
1395
  path: to,
1041
- boundaries: {
1042
- ...(state.boundaries ?? {}),
1043
- [interception.name]: {
1044
- name: interception.name,
1045
- mode: interception.mode,
1046
- status: "ready",
1047
- data: loaded.data,
1048
- ...(interception.errorId === undefined ? {} : { errorId: interception.errorId }),
1049
- },
1050
- },
1051
- actionData: undefined,
1396
+ boundaries: holdTarget(state.boundaries, interception, loaded),
1397
+ intercepted: { boundary: interception.name, pagePath },
1052
1398
  pending: false,
1053
1399
  pendingPath: undefined,
1054
1400
  }
@@ -1063,6 +1409,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1063
1409
  actionData: undefined, // a fresh navigation has no action result
1064
1410
  pending: false,
1065
1411
  }
1412
+ shownPage = loaded
1066
1413
  emit()
1067
1414
  } catch (err) {
1068
1415
  // Clear our pending flag + target (only if still current) and rethrow - the caller decides how
@@ -1077,6 +1424,22 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1077
1424
  submit: async (action, body, opts) => {
1078
1425
  const mine = ++generation
1079
1426
  const previousActionData = state.actionData
1427
+ // An action posted to the target an intercept boundary holds answers that boundary, not the page.
1428
+ const actionBoundary =
1429
+ state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
1430
+ ? state.intercepted.boundary
1431
+ : undefined
1432
+ const previousBoundaryActionData =
1433
+ actionBoundary === undefined ? undefined : state.boundaries?.[actionBoundary]?.actionData
1434
+ // The state with `value` as the action's result, on whichever owns the action.
1435
+ const answered = (
1436
+ current: { readonly boundaries?: BoundaryStates | undefined },
1437
+ value: unknown,
1438
+ ): Partial<RouterState> => {
1439
+ if (actionBoundary === undefined) return { actionData: value }
1440
+ const boundaries = withActionData(current.boundaries, actionBoundary, value)
1441
+ return boundaries === undefined ? {} : { boundaries }
1442
+ }
1080
1443
  // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
1081
1444
  // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
1082
1445
  // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
@@ -1097,7 +1460,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1097
1460
  try {
1098
1461
  const prepared = await prepareClientAction(action, body, ac.signal)
1099
1462
  if (Object.hasOwn(prepared, "optimisticData")) {
1100
- state = { ...state, actionData: prepared.optimisticData }
1463
+ state = { ...state, ...answered(state, prepared.optimisticData) }
1101
1464
  emit()
1102
1465
  }
1103
1466
  const res = await fetch(appUrl(action), {
@@ -1107,6 +1470,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1107
1470
  })
1108
1471
  // Whatever the status, the server took the POST: the action may have run before it failed.
1109
1472
  posted = true
1473
+ dropPrefetched()
1110
1474
  if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
1111
1475
  const redirectTo = res.headers.get(REDIRECT_HEADER)
1112
1476
  if (redirectTo !== null) {
@@ -1135,6 +1499,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1135
1499
  actionData: undefined,
1136
1500
  pending: false,
1137
1501
  }
1502
+ shownPage = loaded
1138
1503
  emit()
1139
1504
  return
1140
1505
  }
@@ -1145,32 +1510,43 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1145
1510
  // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
1146
1511
  // validated against the manifest.
1147
1512
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
1148
- // Revalidate the active route's loader so the mutation is reflected - unless the caller opted
1149
- // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
1150
- // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
1151
- const skipActive = opts?.revalidate === false && !changed.includes(state.path)
1152
- const active = { routeId: state.routeId, params: state.params }
1153
- const loaded = skipActive
1154
- ? {
1155
- ...dataFields(state),
1156
- }
1157
- : await applyClientLoader(state.path, active, ac.signal, () =>
1158
- loadRouteData(state.path, active, ac.signal),
1159
- )
1513
+ // Revalidate what is on screen so the mutation is reflected - the page, and a target an
1514
+ // intercept boundary holds - unless the caller opted out (`revalidate: false`). A
1515
+ // server-declared revalidate of a path on screen overrides the opt-out (the server says it
1516
+ // changed, so stale data would be wrong). Default is to revalidate.
1517
+ const pagePath = pagePathOf(state)
1518
+ const reloadPage = opts?.revalidate !== false || changed.includes(pagePath)
1519
+ const reloadTarget =
1520
+ state.intercepted !== undefined &&
1521
+ (opts?.revalidate !== false || changed.includes(state.path))
1522
+ const { fields, route } = await reloadActive(ac.signal, reloadPage, reloadTarget, mine)
1160
1523
  if (mine !== generation) return
1161
- cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
1162
1524
  // Mark the OTHER changed routes stale so the next access refetches.
1163
- markStale(changed.filter((p) => p !== state.path))
1525
+ markStale(changed.filter((p) => p !== pagePath && (!reloadTarget || p !== state.path)))
1164
1526
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
1165
- // window is over - the real data now drives the view).
1166
- state = {
1167
- routeId: state.routeId,
1168
- params: state.params,
1169
- path: state.path,
1170
- ...dataFields(loaded),
1171
- actionData,
1172
- pending: false,
1173
- }
1527
+ // window is over - the real data now drives the view). A reload that names another route (a
1528
+ // terminal answer for the page) renders it in the page's place, closing any boundary, and the
1529
+ // action's result goes with the page.
1530
+ state =
1531
+ route === undefined
1532
+ ? {
1533
+ routeId: state.routeId,
1534
+ params: state.params,
1535
+ path: state.path,
1536
+ ...fields,
1537
+ actionData: previousActionData,
1538
+ ...answered(fields, actionData),
1539
+ pending: false,
1540
+ ...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
1541
+ }
1542
+ : {
1543
+ routeId: route.routeId,
1544
+ params: route.params,
1545
+ path: state.path,
1546
+ ...fields,
1547
+ actionData: undefined,
1548
+ pending: false,
1549
+ }
1174
1550
  emit()
1175
1551
  // Refresh any mounted fetcher showing one of the changed routes (the active route was just
1176
1552
  // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
@@ -1185,26 +1561,33 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1185
1561
  path: state.path,
1186
1562
  ...dataFields(state),
1187
1563
  actionData: previousActionData,
1564
+ ...(actionBoundary === undefined
1565
+ ? {}
1566
+ : answered(dataFields(state), previousBoundaryActionData)),
1188
1567
  pending: false,
1568
+ ...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
1189
1569
  }
1190
1570
  emit()
1191
1571
  }
1192
1572
  if (!posted) throw err
1193
1573
  if (mine !== generation) return // a newer navigation or submit owns the page
1194
- throw redirectOf(err) === undefined ? redirected(landing ?? state.path, err) : err
1574
+ // Otherwise show the route that owns the action, as a native post to it would.
1575
+ const owner = actionBoundary === undefined ? pagePathOf(state) : state.path
1576
+ throw redirectOf(err) === undefined ? redirected(landing ?? owner, err) : err
1195
1577
  }
1196
1578
  },
1197
1579
  hydrate,
1198
1580
  invalidate: async (paths) => {
1199
1581
  // Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
1200
- // lazily on next access.
1582
+ // lazily on next access. A warmed prefetch of a target is dropped: it would show the old data.
1201
1583
  const targets = paths ?? [...cache.keys()]
1202
1584
  markStale(targets)
1585
+ if (paths === undefined) dropPrefetched()
1203
1586
  // Refresh mounted readers now: the active route (when in scope - an explicit list including it,
1204
1587
  // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
1205
1588
  // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
1206
1589
  const jobs: Promise<void>[] = []
1207
- if (paths === undefined || paths.includes(state.path)) jobs.push(refetchActive())
1590
+ jobs.push(refetchActive(paths === undefined ? undefined : (path) => paths.includes(path)))
1208
1591
  for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(targets))
1209
1592
  await Promise.all(jobs)
1210
1593
  },
@@ -1212,24 +1595,34 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1212
1595
  if (inflight.has(path) || freshPrefetch(path) !== undefined) return
1213
1596
  const matched = match(path)
1214
1597
  if (matched === null) return
1215
- inflight.add(path)
1598
+ const token = {}
1599
+ inflight.set(path, token)
1600
+ const epoch = readEpoch()
1216
1601
  try {
1217
1602
  // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
1218
1603
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
1219
1604
  const [, loaded] = await Promise.all([
1220
1605
  loadModule?.(matched.routeId, path),
1221
- loadRouteData(path, matched),
1606
+ loadRouteData(path, matched, undefined, false, "prefetch"),
1222
1607
  ])
1223
1608
  prefetched.delete(path) // a stale entry re-enters as the newest
1224
1609
  if (prefetched.size >= MAX_PREFETCH) {
1225
1610
  const oldest = prefetched.keys().next().value
1226
1611
  if (oldest !== undefined) prefetched.delete(oldest)
1227
1612
  }
1228
- if (loaded.serverComponents === undefined) prefetched.set(path, [loaded, Date.now()])
1613
+ // A prefetched hole is an empty host nothing fills; the navigation fetches it, cancellably.
1614
+ const holes = Object.values(loaded.boundaries ?? {}).some((state) => state.ppr === true)
1615
+ if (
1616
+ loaded.serverComponents === undefined &&
1617
+ !holes &&
1618
+ inflight.get(path) === token &&
1619
+ epoch === readEpoch()
1620
+ )
1621
+ prefetched.set(path, [loaded, Date.now(), epoch])
1229
1622
  } catch {
1230
1623
  // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
1231
1624
  } finally {
1232
- inflight.delete(path)
1625
+ if (inflight.get(path) === token) inflight.delete(path)
1233
1626
  }
1234
1627
  },
1235
1628
  fetcher: (key) => {