@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/dist/router.js CHANGED
@@ -7,14 +7,21 @@
7
7
  import { decodeRouteParams } from "@nifrajs/core/pattern";
8
8
  import { Router as CoreRouter } from "@nifrajs/core/router";
9
9
  import { parseNdjsonData } from "./deferred.js";
10
+ import { epochOf, IDENTITY_CHANNEL, onIdentityChange } from "./identity.js";
10
11
  import { parsePageData } from "./internal/revive-dates.js";
11
12
  import { appUrl, requestPattern } from "./internal/route-path.js";
13
+ import { shareData } from "./internal/share-data.js";
12
14
  import { isClientOnlySearchChange } from "./search.js";
13
15
  /**
14
16
  * Request header that asks a nifra route's GET to return just the loader data as JSON (instead of
15
17
  * the full HTML document). Set by client-side navigation; read by `createWebApp`'s GET handler.
16
18
  */
17
19
  export const DATA_HEADER = "x-nifra-data";
20
+ /**
21
+ * Sent beside {@link DATA_HEADER} by a prefetch. A partially prerendered route answers it with its
22
+ * shell data and starts no hole: nothing could cancel that work if the link is never followed.
23
+ */
24
+ export const PREFETCH_HEADER = "x-nifra-prefetch";
18
25
  /**
19
26
  * Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
20
27
  * The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
@@ -71,6 +78,18 @@ export const RETAIN_HEADER = "x-nifra-retain";
71
78
  * the full-chain case.
72
79
  */
73
80
  export const NAV_FROM_HEADER = "x-nifra-from";
81
+ /**
82
+ * Sent on a data request that reloads the route instance on screen: the holes, comma separated, whose
83
+ * hosts the partially prerendered page holds. Each one's state then carries its fill (server HTML,
84
+ * plus props when it hydrates) instead of its data, so the client updates the host in place.
85
+ */
86
+ export const HOLE_FILL_HEADER = "x-nifra-hole-fill";
87
+ /**
88
+ * Holes of a partially prerendered page the client keeps, comma separated: they are not loaded, and
89
+ * each state says so. Only the live load endpoint sets it, for holes whose keys did not change. A name
90
+ * the route does not declare is refused with a 400.
91
+ */
92
+ export const KEEP_HOLES_HEADER = "x-nifra-keep-holes";
74
93
  /** Recognise the envelope without mistaking a plain loader object that happens to have a `data` key. */
75
94
  export function isRouteDataEnvelope(value) {
76
95
  return (typeof value === "object" &&
@@ -112,9 +131,16 @@ export function createMatcher(patterns) {
112
131
  /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
113
132
  * `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
114
133
  * navigation fetches and action submits - both transports are identical. */
115
- const readResponseData = (res, signal) => (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
134
+ const readResponseData = (res, signal, read) => (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
116
135
  ? parseNdjsonData(res.body, signal)
117
- : res.text().then(parsePageData);
136
+ : res.text().then((text) => {
137
+ if (read === undefined)
138
+ return parsePageData(text);
139
+ if (text === read.previous)
140
+ return read;
141
+ read.text = text;
142
+ return parsePageData(text);
143
+ });
118
144
  // Index scan, not `/\/+$/` - the unanchored-start trailing-run replace backtracks quadratically and
119
145
  // the pathname is caller-controlled.
120
146
  const trimTrailingSlashes = (path) => {
@@ -169,7 +195,24 @@ const targetUrl = (to) => {
169
195
  }
170
196
  };
171
197
  const redirected = (to, cause) => Object.assign(new Error(`[nifra/web] redirected to ${to}`, { cause }), { redirectTo: to });
172
- const defaultFetchData = async (path, _match, signal, navigation) => {
198
+ /**
199
+ * A route data response as `fetchData` resolves it: a loader's in-app redirect rejects with its
200
+ * target, a terminal status becomes an envelope carrying it, and any other failure rejects.
201
+ */
202
+ export const routeDataOf = async (res, path, signal, read) => {
203
+ const redirectTo = res.headers.get(REDIRECT_HEADER);
204
+ if (redirectTo !== null)
205
+ throw redirected(redirectTo);
206
+ const status = Number(res.headers.get(STATUS_HEADER));
207
+ if (Number.isInteger(status) && status >= 400 && status <= 599) {
208
+ return { v: 1, data: null, status };
209
+ }
210
+ if (!res.ok)
211
+ throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`);
212
+ return readResponseData(res, signal, read);
213
+ };
214
+ /** The router's own transport, which a `fetchData` override may layer over. */
215
+ export const fetchRouteData = async (path, _match, signal, navigation, purpose, hosts, read) => {
173
216
  // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
174
217
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
175
218
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
@@ -184,25 +227,19 @@ const defaultFetchData = async (path, _match, signal, navigation) => {
184
227
  if (prerenderedSetOf(prerendered).has(pathname)) {
185
228
  const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null });
186
229
  if (staticRes.ok)
187
- return readResponseData(staticRes, signal);
230
+ return readResponseData(staticRes, signal, read);
188
231
  }
189
232
  }
190
233
  const headers = new Headers({ [DATA_HEADER]: "1" });
234
+ if (purpose === "prefetch")
235
+ headers.set(PREFETCH_HEADER, "1");
236
+ if (hosts !== undefined && hosts.length > 0)
237
+ headers.set(HOLE_FILL_HEADER, hosts.join(","));
191
238
  if (navigation !== undefined && navigation.retain.length > 0) {
192
239
  headers.set(RETAIN_HEADER, navigation.retain.join(","));
193
240
  headers.set(NAV_FROM_HEADER, navigation.from);
194
241
  }
195
- const res = await fetch(appUrl(path), { headers, signal: signal ?? null });
196
- const redirectTo = res.headers.get(REDIRECT_HEADER);
197
- if (redirectTo !== null)
198
- throw redirected(redirectTo);
199
- const status = Number(res.headers.get(STATUS_HEADER));
200
- if (Number.isInteger(status) && status >= 400 && status <= 599) {
201
- return { v: 1, data: null, status };
202
- }
203
- if (!res.ok)
204
- throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`);
205
- return readResponseData(res, signal);
242
+ return routeDataOf(await fetch(appUrl(path), { headers, signal: signal ?? null }), path, signal, read);
206
243
  };
207
244
  /**
208
245
  * Create the agnostic router store. `navigate` is guarded by a monotonic generation so that when
@@ -224,9 +261,9 @@ const rawSearchOf = (path) => {
224
261
  const isClientActionResult = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
225
262
  export function createClientRouter(options) {
226
263
  const match = createMatcher(options.patterns);
227
- const fetchData = options.fetchData ?? defaultFetchData;
264
+ const fetchData = options.fetchData ?? fetchRouteData;
228
265
  // Start a handed-over list's fetch now, so the first navigation rarely waits for it.
229
- if (fetchData === defaultFetchData)
266
+ if (fetchData === fetchRouteData)
230
267
  prerenderedOf();
231
268
  // routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
232
269
  // to it as routes load), so a same-route nav always sees the current route's keys.
@@ -273,18 +310,68 @@ export function createClientRouter(options) {
273
310
  data: value.data,
274
311
  layoutData: value.layoutData,
275
312
  serverComponents: value.serverComponents,
313
+ live: value.live,
276
314
  ...(value.boundaries === undefined ? {} : { boundaries: value.boundaries }),
277
315
  });
316
+ // The path the page on screen was loaded from. While a boundary holds an intercepted target, `path`
317
+ // is that target's URL, so anything that reloads or caches the page must read this instead.
318
+ const pagePathOf = (current) => current.intercepted?.pagePath ?? current.path;
319
+ // `boundaries` with `name` holding a target's outcome. The URL names one target, so every other
320
+ // intercept boundary goes back to unresolved. A target that answered with a status, or failed, is
321
+ // an error state: its data never reaches the boundary.
322
+ const holdTarget = (boundaries, descriptor, outcome) => {
323
+ const next = {};
324
+ for (const [name, boundary] of Object.entries(boundaries ?? {})) {
325
+ next[name] =
326
+ typeof boundary.mode === "string" || name === descriptor.name
327
+ ? boundary
328
+ : {
329
+ name: boundary.name,
330
+ mode: boundary.mode,
331
+ status: "unresolved",
332
+ ...(boundary.errorId === undefined ? {} : { errorId: boundary.errorId }),
333
+ };
334
+ }
335
+ const base = {
336
+ name: descriptor.name,
337
+ mode: descriptor.mode,
338
+ ...(descriptor.errorId === undefined ? {} : { errorId: descriptor.errorId }),
339
+ };
340
+ next[descriptor.name] =
341
+ outcome === undefined || outcome.terminalRouteId !== undefined
342
+ ? { ...base, status: "error", error: { name: "Error", message: "Boundary failed" } }
343
+ : { ...base, status: "ready", data: outcome.data, live: outcome.live };
344
+ return next;
345
+ };
346
+ // `boundaries` with `name`'s action result set (or cleared, for `undefined`). Only a boundary showing
347
+ // its target's data carries one.
348
+ const withActionData = (boundaries, name, actionData) => {
349
+ const held = boundaries?.[name];
350
+ if (held === undefined || held.status !== "ready")
351
+ return boundaries;
352
+ const { actionData: _previous, ...rest } = held;
353
+ return { ...boundaries, [name]: actionData === undefined ? rest : { ...rest, actionData } };
354
+ };
278
355
  const loadRouteData = async (path, match, signal,
279
356
  // Offer the server the layout data on screen: it re-runs whichever of those loaders it must.
280
- keepLayouts) => {
357
+ keepLayouts, purpose, hosts,
358
+ // What this path shows, when this is a reload of it: the same response text again is that, unparsed.
359
+ shown) => {
281
360
  const retainFrom = keepLayouts
282
- ? state.layoutData && { path: state.path, layoutData: state.layoutData }
361
+ ? state.layoutData && { path: pagePathOf(state), layoutData: state.layoutData }
283
362
  : undefined;
284
363
  const retain = retainFrom?.layoutData.map((_, index) => index) ?? [];
285
- const payload = await fetchData(path, match, signal, retainFrom === undefined ? undefined : { from: retainFrom.path, retain });
364
+ const hooks = routeHooks[match.routeId];
365
+ // A client loader's output is not what the text says, and an opted-out route takes fresh data.
366
+ const read = {
367
+ previous: hooks?.clientLoader === undefined && hooks?.shareData !== false ? shown?.text : undefined,
368
+ };
369
+ const payload = await fetchData(path, match, signal, retainFrom === undefined ? undefined : { from: retainFrom.path, retain }, purpose, hosts, read);
370
+ if (shown !== undefined && payload === read)
371
+ return shown;
372
+ const text = read.text;
286
373
  if (!isRouteDataEnvelope(payload))
287
- return { data: payload };
374
+ return { data: payload, text };
288
375
  if (payload.status !== undefined) {
289
376
  const terminalRouteId = options.statusRoutes?.[payload.status] ?? options.statusRoutes?.[404];
290
377
  if (terminalRouteId === undefined) {
@@ -301,7 +388,27 @@ export function createClientRouter(options) {
301
388
  data: payload.data,
302
389
  layoutData,
303
390
  serverComponents: payload.serverComponents,
391
+ live: payload.live,
304
392
  ...(payload.boundaries !== undefined ? { boundaries: payload.boundaries } : {}),
393
+ text,
394
+ };
395
+ };
396
+ // A reload with every part equal to what its path shows replaced by that part, so what the reload
397
+ // did not change keeps its identity on screen (`shareData`), unless the route exports
398
+ // `shareData = false`.
399
+ const shareShown = (routeId, shown, loaded) => {
400
+ if (loaded.terminalRouteId !== undefined || routeHooks[routeId]?.shareData === false)
401
+ return loaded;
402
+ // Parsed JSON is a tree; a client loader/custom transport can return cyclic or aliased data.
403
+ const plainJson = loaded.text !== undefined && routeHooks[routeId]?.clientLoader === undefined;
404
+ return {
405
+ ...loaded,
406
+ data: shareData(shown.data, loaded.data, plainJson),
407
+ layoutData: shareData(shown.layoutData, loaded.layoutData, plainJson),
408
+ serverComponents: shareData(shown.serverComponents, loaded.serverComponents, plainJson),
409
+ ...(loaded.boundaries === undefined
410
+ ? {}
411
+ : { boundaries: shareData(shown.boundaries, loaded.boundaries, plainJson) }),
305
412
  };
306
413
  };
307
414
  /**
@@ -349,7 +456,10 @@ export function createClientRouter(options) {
349
456
  params: target.params,
350
457
  signal,
351
458
  body,
352
- serverLoader: async () => state.data,
459
+ // An action posted to the target an intercept boundary holds belongs to that target.
460
+ serverLoader: async () => state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
461
+ ? state.boundaries?.[state.intercepted.boundary]?.data
462
+ : state.data,
353
463
  });
354
464
  if (result === undefined)
355
465
  return { body };
@@ -377,19 +487,15 @@ export function createClientRouter(options) {
377
487
  const loaded = await applyClientLoader(state.path, route, ac.signal, () => Promise.resolve(initial), initial);
378
488
  if (mine !== generation)
379
489
  return;
380
- if (loaded.terminalRouteId !== undefined) {
381
- await loadModule?.(loaded.terminalRouteId, state.path);
382
- state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null };
383
- }
384
- else {
385
- state = {
386
- ...state,
387
- ...dataFields(loaded),
388
- };
389
- }
490
+ // No terminal status can arrive here: the server already rendered this route (a status page
491
+ // included) and its data is the only server result the client loader sees.
492
+ state = { ...state, ...dataFields(loaded) };
390
493
  emit();
391
494
  };
392
495
  let state = options.initial;
496
+ // The page as last published from a load, with the response text it was read from. Every publish of
497
+ // page data replaces it, and a reload reuses it only for the same text, which says the same.
498
+ let shownPage;
393
499
  const listeners = new Set();
394
500
  let generation = 0;
395
501
  // Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
@@ -406,12 +512,33 @@ export function createClientRouter(options) {
406
512
  // fresh: a link warmed as it scrolled into view can be clicked minutes later.
407
513
  const MAX_PREFETCH = 10;
408
514
  const PREFETCH_MAX_AGE_MS = 30_000;
515
+ // path -> [data, when it arrived, the identity epoch it was loaded under]
409
516
  const prefetched = new Map();
410
- const inflight = new Set();
517
+ // path -> the token of the prefetch loading it; a prefetch stores what it read only while its
518
+ // token is still there, so dropping the token discards a load that started before the drop.
519
+ const inflight = new Map();
520
+ // Read without the DOM types: this module also builds in tsconfigs that leave the DOM lib out.
521
+ const readEpoch = () => {
522
+ const cookie = Reflect.get(Reflect.get(globalThis, "document") ?? {}, "cookie");
523
+ return typeof cookie === "string" ? epochOf(cookie) : "";
524
+ };
411
525
  const freshPrefetch = (path) => {
412
526
  const held = prefetched.get(path);
413
- return held !== undefined && Date.now() - held[1] < PREFETCH_MAX_AGE_MS ? held[0] : undefined;
527
+ return held !== undefined &&
528
+ Date.now() - held[1] < PREFETCH_MAX_AGE_MS &&
529
+ held[2] === readEpoch()
530
+ ? held[0]
531
+ : undefined;
532
+ };
533
+ // Warmed data was loaded before a mutation or for the previous visitor, so either one drops all of
534
+ // it, along with what a prefetch still in flight read.
535
+ const dropPrefetched = () => {
536
+ inflight.clear();
537
+ prefetched.clear();
414
538
  };
539
+ onIdentityChange(dropPrefetched);
540
+ if (typeof BroadcastChannel !== "undefined")
541
+ new BroadcastChannel(IDENTITY_CHANNEL).addEventListener("message", dropPrefetched);
415
542
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
416
543
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
417
544
  // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
@@ -442,6 +569,8 @@ export function createClientRouter(options) {
442
569
  // `X-Nifra-Revalidate` handling in `submit`.
443
570
  const markStale = (paths) => {
444
571
  for (const p of paths) {
572
+ prefetched.delete(p);
573
+ inflight.delete(p);
445
574
  const entry = cache.get(p);
446
575
  if (entry !== undefined) {
447
576
  cache.set(p, {
@@ -477,6 +606,14 @@ export function createClientRouter(options) {
477
606
  const target = match(path);
478
607
  return target === null ? undefined : [path, target];
479
608
  };
609
+ // Load the status route a terminal answer for `at` renders. One inside layouts needs those layouts'
610
+ // loaders, which only a document request runs, so the caller loads `at` as a document instead.
611
+ const loadTerminal = async (routeId, at) => {
612
+ await loadModule?.(routeId, at);
613
+ if (options.layoutStatusRoutes?.[routeId] === true) {
614
+ throw new Error(`[nifra/web] ${routeId} renders inside layouts; loading ${at} as a document`);
615
+ }
616
+ };
480
617
  // Load `path` for the navigation or submit `mine`, following the redirects its loaders answer with
481
618
  // while they stay in the app (20 at most, as a browser allows).
482
619
  const loadFollowing = async (mine, path, target, signal, keepLayouts, usePrefetch) => {
@@ -494,12 +631,8 @@ export function createClientRouter(options) {
494
631
  prefetched.delete(at);
495
632
  try {
496
633
  const loaded = await applyClientLoader(at, route, signal, () => loadRouteData(at, route, signal, keepLayouts), prefetchedData);
497
- if (loaded.terminalRouteId !== undefined) {
498
- await loadModule?.(loaded.terminalRouteId, at);
499
- if (options.layoutStatusRoutes?.[loaded.terminalRouteId] === true) {
500
- throw new Error(`[nifra/web] ${loaded.terminalRouteId} renders inside layouts; loading ${at} as a document`);
501
- }
502
- }
634
+ if (loaded.terminalRouteId !== undefined)
635
+ await loadTerminal(loaded.terminalRouteId, at);
503
636
  return [at, route, loaded];
504
637
  }
505
638
  catch (error) {
@@ -510,9 +643,91 @@ export function createClientRouter(options) {
510
643
  }
511
644
  }
512
645
  };
513
- // Refetch the active route + republish, single-flight via the shared `generation`. Shared by `invalidate`
514
- // and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
515
- const refetchActive = async () => {
646
+ // Reload what is on screen: the page under its own path when `page`, and, while an intercept boundary
647
+ // holds a target, that target under its URL when `target`. The part not reloaded is kept as shown.
648
+ // Returns the fields to publish, both parts together. `route` is set when the page's reload names
649
+ // another route - the status route it answered with, or, on a status page, its URL's own route
650
+ // answering again - which then replaces the page and any boundary over it, so data is only ever
651
+ // rendered by the route it was loaded for. A target that fails becomes its
652
+ // boundary's error state rather than failing the page, as a dynamic boundary's load does. Nothing is
653
+ // cached once `mine` is superseded: a newer action's reload owns the cache.
654
+ const reloadActive = async (signal, page, target, mine) => {
655
+ const pagePath = pagePathOf(state);
656
+ const intercepted = state.intercepted;
657
+ const targetPath = state.path;
658
+ // The route the page's URL matches: the one on screen, except on a status page.
659
+ const pageRoute = match(pagePath) ?? { routeId: state.routeId, params: state.params };
660
+ const rematched = pageRoute.routeId !== state.routeId;
661
+ const targetRoute = intercepted !== undefined && target ? match(targetPath) : null;
662
+ // The page's own route shares with what the page shows; a status page answered by its URL's route
663
+ // has nothing of that route on screen.
664
+ const shown = rematched ? undefined : (shownPage ?? state);
665
+ const shownTarget = { data: intercepted && state.boundaries?.[intercepted.boundary]?.data };
666
+ // A hole on screen that is a host stays one: it reloads as a fill.
667
+ const hosts = rematched
668
+ ? []
669
+ : Object.values(state.boundaries ?? {})
670
+ .filter((held) => held.ppr === true)
671
+ .map((held) => held.name);
672
+ const [reloaded, targetOutcome] = await Promise.all([
673
+ page
674
+ ? (async () => {
675
+ if (rematched)
676
+ await loadModule?.(pageRoute.routeId, pagePath);
677
+ const loaded = await applyClientLoader(pagePath, pageRoute, signal, () => loadRouteData(pagePath, pageRoute, signal, false, undefined, hosts.length === 0 ? undefined : hosts, shown));
678
+ return shown === undefined ? loaded : shareShown(pageRoute.routeId, shown, loaded);
679
+ })()
680
+ : undefined,
681
+ targetRoute === null
682
+ ? undefined
683
+ : (async () => {
684
+ try {
685
+ await loadModule?.(targetRoute.routeId, targetPath);
686
+ const outcome = shareShown(targetRoute.routeId, shownTarget, await applyClientLoader(targetPath, targetRoute, signal, () => loadRouteData(targetPath, targetRoute, signal)));
687
+ if (mine === generation)
688
+ cachePut(targetPath, outcome);
689
+ return outcome;
690
+ }
691
+ catch {
692
+ return undefined;
693
+ }
694
+ })(),
695
+ ]);
696
+ if (reloaded?.terminalRouteId !== undefined)
697
+ await loadTerminal(reloaded.terminalRouteId, pagePath);
698
+ if (reloaded !== undefined && mine === generation) {
699
+ cachePut(pagePath, reloaded);
700
+ shownPage = reloaded;
701
+ }
702
+ const fields = dataFields(reloaded ?? state);
703
+ if (reloaded?.terminalRouteId !== undefined)
704
+ return { fields, route: { routeId: reloaded.terminalRouteId, params: {} } };
705
+ if (reloaded !== undefined && rematched)
706
+ return { fields, route: pageRoute };
707
+ if (intercepted === undefined)
708
+ return { fields };
709
+ const held = state.boundaries?.[intercepted.boundary];
710
+ if (held === undefined)
711
+ return { fields };
712
+ if (targetRoute === null)
713
+ return {
714
+ fields: {
715
+ ...fields,
716
+ boundaries: { ...(fields.boundaries ?? {}), [intercepted.boundary]: held },
717
+ },
718
+ };
719
+ // A reload keeps the result of the last action posted to the target, as the page keeps its own.
720
+ const boundaries = withActionData(holdTarget(fields.boundaries, held, targetOutcome), intercepted.boundary, held.actionData);
721
+ return { fields: { ...fields, ...(boundaries === undefined ? {} : { boundaries }) } };
722
+ };
723
+ // Refetch what is on screen + republish, single-flight via the shared `generation`. Shared by
724
+ // `invalidate` and targeted revalidation: `inScope` says which paths to refresh, and a page or held
725
+ // target outside it is kept. Rejects if the page's fetch fails (clearing its own `pending` first).
726
+ const refetchActive = async (inScope) => {
727
+ const page = inScope === undefined || inScope(pagePathOf(state));
728
+ const target = state.intercepted !== undefined && (inScope === undefined || inScope(state.path));
729
+ if (!page && !target)
730
+ return;
516
731
  const mine = ++generation;
517
732
  navAbort?.abort();
518
733
  const ac = new AbortController();
@@ -520,16 +735,22 @@ export function createClientRouter(options) {
520
735
  state = { ...state, pending: true };
521
736
  emit();
522
737
  try {
523
- const active = { routeId: state.routeId, params: state.params };
524
- const loaded = await applyClientLoader(state.path, active, ac.signal, () => loadRouteData(state.path, active, ac.signal));
738
+ const { fields, route } = await reloadActive(ac.signal, page, target, mine);
525
739
  if (mine !== generation)
526
740
  return; // superseded - drop the stale result
527
- cachePut(state.path, loaded);
528
- state = {
529
- ...state,
530
- ...dataFields(loaded),
531
- pending: false,
532
- };
741
+ // A terminal answer for the page (the session expired, the record is gone) replaces it, and any
742
+ // boundary over it, with its status route, as a navigation to this URL would.
743
+ state =
744
+ route === undefined
745
+ ? { ...state, ...fields, pending: false }
746
+ : {
747
+ routeId: route.routeId,
748
+ params: route.params,
749
+ path: state.path,
750
+ ...fields,
751
+ actionData: undefined,
752
+ pending: false,
753
+ };
533
754
  emit();
534
755
  }
535
756
  catch (err) {
@@ -558,8 +779,8 @@ export function createClientRouter(options) {
558
779
  // swallowed - a failed targeted refresh must not fail the mutation that triggered it.
559
780
  const refreshMounted = async (paths, skipActive) => {
560
781
  const jobs = [];
561
- if (!skipActive && paths.includes(state.path))
562
- jobs.push(refetchActive().catch(() => { }));
782
+ if (!skipActive)
783
+ jobs.push(refetchActive((path) => paths.includes(path)).catch(() => { }));
563
784
  for (const entry of fetchers.values())
564
785
  jobs.push(entry.refreshIfShowing(paths));
565
786
  await Promise.all(jobs);
@@ -569,6 +790,8 @@ export function createClientRouter(options) {
569
790
  let fGeneration = 0;
570
791
  let fAbort;
571
792
  let loadedPath;
793
+ // `data` as loaded, with the response text it was read from.
794
+ let shown;
572
795
  const fListeners = new Set();
573
796
  const fEmit = () => {
574
797
  if (fListeners.size > 0)
@@ -587,12 +810,16 @@ export function createClientRouter(options) {
587
810
  fState = { ...fState, pending: true };
588
811
  fEmit();
589
812
  try {
590
- const [, loaded] = await Promise.all([
813
+ // Loading the path again is a reload of what this fetcher shows.
814
+ const before = path === loadedPath ? shown : undefined;
815
+ const [, fresh] = await Promise.all([
591
816
  loadModule?.(matched.routeId),
592
- loadRouteData(path, matched, ac.signal),
817
+ loadRouteData(path, matched, ac.signal, false, undefined, undefined, before),
593
818
  ]);
594
819
  if (mine !== fGeneration)
595
820
  return; // a newer load/submit on THIS fetcher superseded us
821
+ const loaded = before === undefined ? fresh : shareShown(matched.routeId, before, fresh);
822
+ shown = loaded;
596
823
  // Record the loaded path only on SUCCESS - a thrown or superseded load must not
597
824
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
598
825
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
@@ -640,6 +867,7 @@ export function createClientRouter(options) {
640
867
  body,
641
868
  headers: { [DATA_HEADER]: "1" },
642
869
  });
870
+ dropPrefetched();
643
871
  if (!res.ok)
644
872
  throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`);
645
873
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal);
@@ -687,12 +915,23 @@ export function createClientRouter(options) {
687
915
  const matched = match(path);
688
916
  if (matched === null)
689
917
  return;
918
+ // The target an open boundary already shows (a `#fragment` entry inside it, a link to itself):
919
+ // reloading it would rebuild the boundary and drop its action result. One in its error state
920
+ // loads again, so a link can retry it.
921
+ if (state.intercepted !== undefined &&
922
+ state.boundaries?.[state.intercepted.boundary]?.status === "ready" &&
923
+ !state.pending &&
924
+ path === state.path) {
925
+ emit();
926
+ return;
927
+ }
690
928
  // Client-only search fast path: same route + same pathname, and every changed search key is
691
929
  // declared client-only. Publish the new URL (so `useSearch`/`useLocation` update) WITHOUT a data
692
930
  // fetch - the loader's inputs did not change. Any loader-affecting key change falls through to the
693
931
  // normal match → fetch → publish below, so data is never stale. Abort an in-flight fetch first: a
694
932
  // rapid `?tab` after a real nav must not let the older fetch overwrite this synchronous update.
695
- if (matched.routeId === state.routeId &&
933
+ if (state.intercepted === undefined &&
934
+ matched.routeId === state.routeId &&
696
935
  pathnameOf(path) === pathnameOf(state.path) &&
697
936
  isClientOnlySearchChange(rawSearchOf(state.path), rawSearchOf(path), searchClientKeys[state.routeId] ?? [])) {
698
937
  ++generation; // supersede any in-flight navigation so its late result is dropped
@@ -713,7 +952,10 @@ export function createClientRouter(options) {
713
952
  if (mine !== generation)
714
953
  return; // a newer navigation superseded this one - drop the stale result
715
954
  cachePut(to, loaded); // keep the keyed cache coherent with what we publish
716
- const interception = loaded.terminalRouteId === undefined
955
+ // The page's own URL is never intercepted: navigating there (Back out of a boundary, a link
956
+ // to itself) shows the page, even when a boundary's pattern matches it.
957
+ const pagePath = pagePathOf(state);
958
+ const interception = loaded.terminalRouteId === undefined && pathnameOf(to) !== pathnameOf(pagePath)
717
959
  ? interceptionFor(state.routeId, to, target)
718
960
  : undefined;
719
961
  if (interception !== undefined) {
@@ -724,17 +966,8 @@ export function createClientRouter(options) {
724
966
  state = {
725
967
  ...state,
726
968
  path: to,
727
- boundaries: {
728
- ...(state.boundaries ?? {}),
729
- [interception.name]: {
730
- name: interception.name,
731
- mode: interception.mode,
732
- status: "ready",
733
- data: loaded.data,
734
- ...(interception.errorId === undefined ? {} : { errorId: interception.errorId }),
735
- },
736
- },
737
- actionData: undefined,
969
+ boundaries: holdTarget(state.boundaries, interception, loaded),
970
+ intercepted: { boundary: interception.name, pagePath },
738
971
  pending: false,
739
972
  pendingPath: undefined,
740
973
  };
@@ -749,6 +982,7 @@ export function createClientRouter(options) {
749
982
  actionData: undefined, // a fresh navigation has no action result
750
983
  pending: false,
751
984
  };
985
+ shownPage = loaded;
752
986
  emit();
753
987
  }
754
988
  catch (err) {
@@ -764,6 +998,18 @@ export function createClientRouter(options) {
764
998
  submit: async (action, body, opts) => {
765
999
  const mine = ++generation;
766
1000
  const previousActionData = state.actionData;
1001
+ // An action posted to the target an intercept boundary holds answers that boundary, not the page.
1002
+ const actionBoundary = state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
1003
+ ? state.intercepted.boundary
1004
+ : undefined;
1005
+ const previousBoundaryActionData = actionBoundary === undefined ? undefined : state.boundaries?.[actionBoundary]?.actionData;
1006
+ // The state with `value` as the action's result, on whichever owns the action.
1007
+ const answered = (current, value) => {
1008
+ if (actionBoundary === undefined)
1009
+ return { actionData: value };
1010
+ const boundaries = withActionData(current.boundaries, actionBoundary, value);
1011
+ return boundaries === undefined ? {} : { boundaries };
1012
+ };
767
1013
  // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
768
1014
  // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
769
1015
  // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
@@ -784,7 +1030,7 @@ export function createClientRouter(options) {
784
1030
  try {
785
1031
  const prepared = await prepareClientAction(action, body, ac.signal);
786
1032
  if (Object.hasOwn(prepared, "optimisticData")) {
787
- state = { ...state, actionData: prepared.optimisticData };
1033
+ state = { ...state, ...answered(state, prepared.optimisticData) };
788
1034
  emit();
789
1035
  }
790
1036
  const res = await fetch(appUrl(action), {
@@ -794,6 +1040,7 @@ export function createClientRouter(options) {
794
1040
  });
795
1041
  // Whatever the status, the server took the POST: the action may have run before it failed.
796
1042
  posted = true;
1043
+ dropPrefetched();
797
1044
  if (!res.ok)
798
1045
  throw new Error(`[nifra/web] action failed (${res.status}): ${action}`);
799
1046
  const redirectTo = res.headers.get(REDIRECT_HEADER);
@@ -818,6 +1065,7 @@ export function createClientRouter(options) {
818
1065
  actionData: undefined,
819
1066
  pending: false,
820
1067
  };
1068
+ shownPage = loaded;
821
1069
  emit();
822
1070
  return;
823
1071
  }
@@ -828,31 +1076,43 @@ export function createClientRouter(options) {
828
1076
  // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
829
1077
  // validated against the manifest.
830
1078
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER));
831
- // Revalidate the active route's loader so the mutation is reflected - unless the caller opted
832
- // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
833
- // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
834
- const skipActive = opts?.revalidate === false && !changed.includes(state.path);
835
- const active = { routeId: state.routeId, params: state.params };
836
- const loaded = skipActive
837
- ? {
838
- ...dataFields(state),
839
- }
840
- : await applyClientLoader(state.path, active, ac.signal, () => loadRouteData(state.path, active, ac.signal));
1079
+ // Revalidate what is on screen so the mutation is reflected - the page, and a target an
1080
+ // intercept boundary holds - unless the caller opted out (`revalidate: false`). A
1081
+ // server-declared revalidate of a path on screen overrides the opt-out (the server says it
1082
+ // changed, so stale data would be wrong). Default is to revalidate.
1083
+ const pagePath = pagePathOf(state);
1084
+ const reloadPage = opts?.revalidate !== false || changed.includes(pagePath);
1085
+ const reloadTarget = state.intercepted !== undefined &&
1086
+ (opts?.revalidate !== false || changed.includes(state.path));
1087
+ const { fields, route } = await reloadActive(ac.signal, reloadPage, reloadTarget, mine);
841
1088
  if (mine !== generation)
842
1089
  return;
843
- cachePut(state.path, loaded); // the revalidated (or kept) data is now the cache's truth
844
1090
  // Mark the OTHER changed routes stale so the next access refetches.
845
- markStale(changed.filter((p) => p !== state.path));
1091
+ markStale(changed.filter((p) => p !== pagePath && (!reloadTarget || p !== state.path)));
846
1092
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
847
- // window is over - the real data now drives the view).
848
- state = {
849
- routeId: state.routeId,
850
- params: state.params,
851
- path: state.path,
852
- ...dataFields(loaded),
853
- actionData,
854
- pending: false,
855
- };
1093
+ // window is over - the real data now drives the view). A reload that names another route (a
1094
+ // terminal answer for the page) renders it in the page's place, closing any boundary, and the
1095
+ // action's result goes with the page.
1096
+ state =
1097
+ route === undefined
1098
+ ? {
1099
+ routeId: state.routeId,
1100
+ params: state.params,
1101
+ path: state.path,
1102
+ ...fields,
1103
+ actionData: previousActionData,
1104
+ ...answered(fields, actionData),
1105
+ pending: false,
1106
+ ...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
1107
+ }
1108
+ : {
1109
+ routeId: route.routeId,
1110
+ params: route.params,
1111
+ path: state.path,
1112
+ ...fields,
1113
+ actionData: undefined,
1114
+ pending: false,
1115
+ };
856
1116
  emit();
857
1117
  // Refresh any mounted fetcher showing one of the changed routes (the active route was just
858
1118
  // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
@@ -868,7 +1128,11 @@ export function createClientRouter(options) {
868
1128
  path: state.path,
869
1129
  ...dataFields(state),
870
1130
  actionData: previousActionData,
1131
+ ...(actionBoundary === undefined
1132
+ ? {}
1133
+ : answered(dataFields(state), previousBoundaryActionData)),
871
1134
  pending: false,
1135
+ ...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
872
1136
  };
873
1137
  emit();
874
1138
  }
@@ -876,21 +1140,24 @@ export function createClientRouter(options) {
876
1140
  throw err;
877
1141
  if (mine !== generation)
878
1142
  return; // a newer navigation or submit owns the page
879
- throw redirectOf(err) === undefined ? redirected(landing ?? state.path, err) : err;
1143
+ // Otherwise show the route that owns the action, as a native post to it would.
1144
+ const owner = actionBoundary === undefined ? pagePathOf(state) : state.path;
1145
+ throw redirectOf(err) === undefined ? redirected(landing ?? owner, err) : err;
880
1146
  }
881
1147
  },
882
1148
  hydrate,
883
1149
  invalidate: async (paths) => {
884
1150
  // Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
885
- // lazily on next access.
1151
+ // lazily on next access. A warmed prefetch of a target is dropped: it would show the old data.
886
1152
  const targets = paths ?? [...cache.keys()];
887
1153
  markStale(targets);
1154
+ if (paths === undefined)
1155
+ dropPrefetched();
888
1156
  // Refresh mounted readers now: the active route (when in scope - an explicit list including it,
889
1157
  // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
890
1158
  // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
891
1159
  const jobs = [];
892
- if (paths === undefined || paths.includes(state.path))
893
- jobs.push(refetchActive());
1160
+ jobs.push(refetchActive(paths === undefined ? undefined : (path) => paths.includes(path)));
894
1161
  for (const entry of fetchers.values())
895
1162
  jobs.push(entry.refreshIfShowing(targets));
896
1163
  await Promise.all(jobs);
@@ -901,13 +1168,15 @@ export function createClientRouter(options) {
901
1168
  const matched = match(path);
902
1169
  if (matched === null)
903
1170
  return;
904
- inflight.add(path);
1171
+ const token = {};
1172
+ inflight.set(path, token);
1173
+ const epoch = readEpoch();
905
1174
  try {
906
1175
  // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
907
1176
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
908
1177
  const [, loaded] = await Promise.all([
909
1178
  loadModule?.(matched.routeId, path),
910
- loadRouteData(path, matched),
1179
+ loadRouteData(path, matched, undefined, false, "prefetch"),
911
1180
  ]);
912
1181
  prefetched.delete(path); // a stale entry re-enters as the newest
913
1182
  if (prefetched.size >= MAX_PREFETCH) {
@@ -915,14 +1184,20 @@ export function createClientRouter(options) {
915
1184
  if (oldest !== undefined)
916
1185
  prefetched.delete(oldest);
917
1186
  }
918
- if (loaded.serverComponents === undefined)
919
- prefetched.set(path, [loaded, Date.now()]);
1187
+ // A prefetched hole is an empty host nothing fills; the navigation fetches it, cancellably.
1188
+ const holes = Object.values(loaded.boundaries ?? {}).some((state) => state.ppr === true);
1189
+ if (loaded.serverComponents === undefined &&
1190
+ !holes &&
1191
+ inflight.get(path) === token &&
1192
+ epoch === readEpoch())
1193
+ prefetched.set(path, [loaded, Date.now(), epoch]);
920
1194
  }
921
1195
  catch {
922
1196
  // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
923
1197
  }
924
1198
  finally {
925
- inflight.delete(path);
1199
+ if (inflight.get(path) === token)
1200
+ inflight.delete(path);
926
1201
  }
927
1202
  },
928
1203
  fetcher: (key) => {