@nifrajs/web 5.5.1 → 5.7.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 (481) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +21 -11
  3. package/dist/boundary.d.ts +67 -7
  4. package/dist/boundary.d.ts.map +1 -1
  5. package/dist/boundary.js +6 -4
  6. package/dist/boundary.js.map +1 -1
  7. package/dist/build-plan.d.ts +3 -0
  8. package/dist/build-plan.d.ts.map +1 -1
  9. package/dist/build-plan.js.map +1 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +2 -0
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +9 -2
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +10 -0
  16. package/dist/build.js.map +1 -1
  17. package/dist/client.d.ts +3 -1
  18. package/dist/client.d.ts.map +1 -1
  19. package/dist/client.js +174 -23
  20. package/dist/client.js.map +1 -1
  21. package/dist/csp.d.ts +8 -0
  22. package/dist/csp.d.ts.map +1 -1
  23. package/dist/csp.js +11 -2
  24. package/dist/csp.js.map +1 -1
  25. package/dist/deferred.d.ts +6 -4
  26. package/dist/deferred.d.ts.map +1 -1
  27. package/dist/deferred.js +10 -6
  28. package/dist/deferred.js.map +1 -1
  29. package/dist/dev-feed.d.ts.map +1 -1
  30. package/dist/dev-feed.js +11 -2
  31. package/dist/dev-feed.js.map +1 -1
  32. package/dist/dev-session.d.ts.map +1 -1
  33. package/dist/dev-session.js +13 -6
  34. package/dist/dev-session.js.map +1 -1
  35. package/dist/dev.d.ts +4 -1
  36. package/dist/dev.d.ts.map +1 -1
  37. package/dist/dev.js +12 -5
  38. package/dist/dev.js.map +1 -1
  39. package/dist/diagnostic-catalog.d.ts +4 -0
  40. package/dist/diagnostic-catalog.d.ts.map +1 -1
  41. package/dist/diagnostic-catalog.js +48 -0
  42. package/dist/diagnostic-catalog.js.map +1 -1
  43. package/dist/diagnostic.d.ts +1 -1
  44. package/dist/diagnostic.d.ts.map +1 -1
  45. package/dist/diagnostic.js +1 -1
  46. package/dist/diagnostic.js.map +1 -1
  47. package/dist/fn.d.ts +5 -3
  48. package/dist/fn.d.ts.map +1 -1
  49. package/dist/fn.js +20 -5
  50. package/dist/fn.js.map +1 -1
  51. package/dist/fs.d.ts.map +1 -1
  52. package/dist/fs.js +13 -3
  53. package/dist/fs.js.map +1 -1
  54. package/dist/identity.d.ts +22 -0
  55. package/dist/identity.d.ts.map +1 -0
  56. package/dist/identity.js +60 -0
  57. package/dist/identity.js.map +1 -0
  58. package/dist/index.d.ts +8 -6
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +6 -3
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal/cdn/cloudflare.d.ts +2 -1
  63. package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
  64. package/dist/internal/cdn/cloudflare.js +2 -1
  65. package/dist/internal/cdn/cloudflare.js.map +1 -1
  66. package/dist/internal/certified-components.d.ts +1 -1
  67. package/dist/internal/certified-components.js +1 -1
  68. package/dist/internal/codegen.d.ts +11 -10
  69. package/dist/internal/codegen.d.ts.map +1 -1
  70. package/dist/internal/codegen.js +73 -10
  71. package/dist/internal/codegen.js.map +1 -1
  72. package/dist/internal/dev-websocket.d.ts +73 -0
  73. package/dist/internal/dev-websocket.d.ts.map +1 -0
  74. package/dist/internal/dev-websocket.js +145 -0
  75. package/dist/internal/dev-websocket.js.map +1 -0
  76. package/dist/internal/hole-runtime.d.ts +66 -0
  77. package/dist/internal/hole-runtime.d.ts.map +1 -0
  78. package/dist/internal/hole-runtime.js +211 -0
  79. package/dist/internal/hole-runtime.js.map +1 -0
  80. package/dist/internal/identity-epoch.d.ts +50 -0
  81. package/dist/internal/identity-epoch.d.ts.map +1 -0
  82. package/dist/internal/identity-epoch.js +180 -0
  83. package/dist/internal/identity-epoch.js.map +1 -0
  84. package/dist/internal/invalidation-contract.d.ts +23 -0
  85. package/dist/internal/invalidation-contract.d.ts.map +1 -0
  86. package/dist/internal/invalidation-contract.js +11 -0
  87. package/dist/internal/invalidation-contract.js.map +1 -0
  88. package/dist/internal/invalidation.d.ts +62 -0
  89. package/dist/internal/invalidation.d.ts.map +1 -0
  90. package/dist/internal/invalidation.js +149 -0
  91. package/dist/internal/invalidation.js.map +1 -0
  92. package/dist/internal/island-bindings.d.ts +51 -0
  93. package/dist/internal/island-bindings.d.ts.map +1 -0
  94. package/dist/internal/island-bindings.js +147 -0
  95. package/dist/internal/island-bindings.js.map +1 -0
  96. package/dist/internal/island-list.d.ts +26 -0
  97. package/dist/internal/island-list.d.ts.map +1 -0
  98. package/dist/internal/island-list.js +78 -0
  99. package/dist/internal/island-list.js.map +1 -0
  100. package/dist/internal/island-mount.d.ts +4 -0
  101. package/dist/internal/island-mount.d.ts.map +1 -0
  102. package/dist/internal/island-mount.js +66 -0
  103. package/dist/internal/island-mount.js.map +1 -0
  104. package/dist/internal/island-owner.d.ts +5 -0
  105. package/dist/internal/island-owner.d.ts.map +1 -0
  106. package/dist/internal/island-owner.js +20 -0
  107. package/dist/internal/island-owner.js.map +1 -0
  108. package/dist/internal/island-resource.d.ts +30 -0
  109. package/dist/internal/island-resource.d.ts.map +1 -0
  110. package/dist/internal/island-resource.js +57 -0
  111. package/dist/internal/island-resource.js.map +1 -0
  112. package/dist/internal/island-signals.d.ts +51 -0
  113. package/dist/internal/island-signals.d.ts.map +1 -0
  114. package/dist/internal/island-signals.js +220 -0
  115. package/dist/internal/island-signals.js.map +1 -0
  116. package/dist/internal/keep-alive-table.d.ts +23 -0
  117. package/dist/internal/keep-alive-table.d.ts.map +1 -0
  118. package/dist/internal/keep-alive-table.js +53 -0
  119. package/dist/internal/keep-alive-table.js.map +1 -0
  120. package/dist/internal/live-contract.d.ts +101 -0
  121. package/dist/internal/live-contract.d.ts.map +1 -0
  122. package/dist/internal/live-contract.js +5 -0
  123. package/dist/internal/live-contract.js.map +1 -0
  124. package/dist/internal/live-queries.d.ts +22 -0
  125. package/dist/internal/live-queries.d.ts.map +1 -0
  126. package/dist/internal/live-queries.js +54 -0
  127. package/dist/internal/live-queries.js.map +1 -0
  128. package/dist/internal/page-events.d.ts +10 -0
  129. package/dist/internal/page-events.d.ts.map +1 -0
  130. package/dist/internal/page-events.js +26 -0
  131. package/dist/internal/page-events.js.map +1 -0
  132. package/dist/internal/page-execution.d.ts +16 -1
  133. package/dist/internal/page-execution.d.ts.map +1 -1
  134. package/dist/internal/page-execution.js +432 -31
  135. package/dist/internal/page-execution.js.map +1 -1
  136. package/dist/internal/parity.d.ts +3 -0
  137. package/dist/internal/parity.d.ts.map +1 -1
  138. package/dist/internal/parity.js +28 -5
  139. package/dist/internal/parity.js.map +1 -1
  140. package/dist/internal/ppr/admission.d.ts +83 -0
  141. package/dist/internal/ppr/admission.d.ts.map +1 -0
  142. package/dist/internal/ppr/admission.js +221 -0
  143. package/dist/internal/ppr/admission.js.map +1 -0
  144. package/dist/internal/ppr/canary.d.ts +24 -0
  145. package/dist/internal/ppr/canary.d.ts.map +1 -0
  146. package/dist/internal/ppr/canary.js +102 -0
  147. package/dist/internal/ppr/canary.js.map +1 -0
  148. package/dist/internal/ppr/compile-shell.d.ts +39 -0
  149. package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
  150. package/dist/internal/ppr/compile-shell.js +298 -0
  151. package/dist/internal/ppr/compile-shell.js.map +1 -0
  152. package/dist/internal/ppr/contract.d.ts +177 -0
  153. package/dist/internal/ppr/contract.d.ts.map +1 -0
  154. package/dist/internal/ppr/contract.js +80 -0
  155. package/dist/internal/ppr/contract.js.map +1 -0
  156. package/dist/internal/ppr/frames.d.ts +71 -0
  157. package/dist/internal/ppr/frames.d.ts.map +1 -0
  158. package/dist/internal/ppr/frames.js +119 -0
  159. package/dist/internal/ppr/frames.js.map +1 -0
  160. package/dist/internal/ppr/integration.d.ts +19 -0
  161. package/dist/internal/ppr/integration.d.ts.map +1 -0
  162. package/dist/internal/ppr/integration.js +106 -0
  163. package/dist/internal/ppr/integration.js.map +1 -0
  164. package/dist/internal/ppr/options.d.ts +96 -0
  165. package/dist/internal/ppr/options.d.ts.map +1 -0
  166. package/dist/internal/ppr/options.js +125 -0
  167. package/dist/internal/ppr/options.js.map +1 -0
  168. package/dist/internal/ppr/route-rules.d.ts +12 -0
  169. package/dist/internal/ppr/route-rules.d.ts.map +1 -0
  170. package/dist/internal/ppr/route-rules.js +53 -0
  171. package/dist/internal/ppr/route-rules.js.map +1 -0
  172. package/dist/internal/ppr/runtime.d.ts +29 -0
  173. package/dist/internal/ppr/runtime.d.ts.map +1 -0
  174. package/dist/internal/ppr/runtime.js +35 -0
  175. package/dist/internal/ppr/runtime.js.map +1 -0
  176. package/dist/internal/ppr/server.d.ts +29 -0
  177. package/dist/internal/ppr/server.d.ts.map +1 -0
  178. package/dist/internal/ppr/server.js +1409 -0
  179. package/dist/internal/ppr/server.js.map +1 -0
  180. package/dist/internal/ppr/shell-store.d.ts +39 -0
  181. package/dist/internal/ppr/shell-store.d.ts.map +1 -0
  182. package/dist/internal/ppr/shell-store.js +106 -0
  183. package/dist/internal/ppr/shell-store.js.map +1 -0
  184. package/dist/internal/prerendered-pages.d.ts +1 -1
  185. package/dist/internal/prerendered-pages.d.ts.map +1 -1
  186. package/dist/internal/prerendered-pages.js +7 -4
  187. package/dist/internal/prerendered-pages.js.map +1 -1
  188. package/dist/internal/present.d.ts +11 -0
  189. package/dist/internal/present.d.ts.map +1 -0
  190. package/dist/internal/present.js +13 -0
  191. package/dist/internal/present.js.map +1 -0
  192. package/dist/internal/query-digest.d.ts +9 -0
  193. package/dist/internal/query-digest.d.ts.map +1 -0
  194. package/dist/internal/query-digest.js +23 -0
  195. package/dist/internal/query-digest.js.map +1 -0
  196. package/dist/internal/query-runtime.d.ts +6 -1
  197. package/dist/internal/query-runtime.d.ts.map +1 -1
  198. package/dist/internal/query-runtime.js +9 -0
  199. package/dist/internal/query-runtime.js.map +1 -1
  200. package/dist/internal/query-seam.d.ts +29 -0
  201. package/dist/internal/query-seam.d.ts.map +1 -0
  202. package/dist/internal/query-seam.js +23 -0
  203. package/dist/internal/query-seam.js.map +1 -0
  204. package/dist/internal/render-document.d.ts +34 -64
  205. package/dist/internal/render-document.d.ts.map +1 -1
  206. package/dist/internal/render-document.js +82 -64
  207. package/dist/internal/render-document.js.map +1 -1
  208. package/dist/internal/request-abort.d.ts +8 -0
  209. package/dist/internal/request-abort.d.ts.map +1 -0
  210. package/dist/internal/request-abort.js +22 -0
  211. package/dist/internal/request-abort.js.map +1 -0
  212. package/dist/internal/server-boundary.d.ts +2 -0
  213. package/dist/internal/server-boundary.d.ts.map +1 -1
  214. package/dist/internal/server-boundary.js +8 -1
  215. package/dist/internal/server-boundary.js.map +1 -1
  216. package/dist/internal/share-data.d.ts +10 -0
  217. package/dist/internal/share-data.d.ts.map +1 -0
  218. package/dist/internal/share-data.js +135 -0
  219. package/dist/internal/share-data.js.map +1 -0
  220. package/dist/internal/status-page.d.ts +68 -0
  221. package/dist/internal/status-page.d.ts.map +1 -0
  222. package/dist/internal/status-page.js +66 -0
  223. package/dist/internal/status-page.js.map +1 -0
  224. package/dist/internal/store-signature.d.ts +15 -0
  225. package/dist/internal/store-signature.d.ts.map +1 -0
  226. package/dist/internal/store-signature.js +114 -0
  227. package/dist/internal/store-signature.js.map +1 -0
  228. package/dist/internal/vite-plugin-compat.d.ts +2 -2
  229. package/dist/internal/vite-plugin-compat.js +2 -2
  230. package/dist/internal/web-app.d.ts +26 -0
  231. package/dist/internal/web-app.d.ts.map +1 -1
  232. package/dist/internal/web-app.js +79 -3
  233. package/dist/internal/web-app.js.map +1 -1
  234. package/dist/islands-swap.d.ts +41 -0
  235. package/dist/islands-swap.d.ts.map +1 -0
  236. package/dist/islands-swap.js +270 -0
  237. package/dist/islands-swap.js.map +1 -0
  238. package/dist/islands.d.ts +86 -24
  239. package/dist/islands.d.ts.map +1 -1
  240. package/dist/islands.js +138 -78
  241. package/dist/islands.js.map +1 -1
  242. package/dist/isr.d.ts +67 -13
  243. package/dist/isr.d.ts.map +1 -1
  244. package/dist/isr.js +247 -99
  245. package/dist/isr.js.map +1 -1
  246. package/dist/keep-alive.d.ts +147 -0
  247. package/dist/keep-alive.d.ts.map +1 -0
  248. package/dist/keep-alive.js +1102 -0
  249. package/dist/keep-alive.js.map +1 -0
  250. package/dist/live/bus.d.ts +70 -0
  251. package/dist/live/bus.d.ts.map +1 -0
  252. package/dist/live/bus.js +55 -0
  253. package/dist/live/bus.js.map +1 -0
  254. package/dist/live/core.d.ts +25 -0
  255. package/dist/live/core.d.ts.map +1 -0
  256. package/dist/live/core.js +29 -0
  257. package/dist/live/core.js.map +1 -0
  258. package/dist/live/history.d.ts +38 -0
  259. package/dist/live/history.d.ts.map +1 -0
  260. package/dist/live/history.js +104 -0
  261. package/dist/live/history.js.map +1 -0
  262. package/dist/live/hub-protocol.d.ts +46 -0
  263. package/dist/live/hub-protocol.d.ts.map +1 -0
  264. package/dist/live/hub-protocol.js +108 -0
  265. package/dist/live/hub-protocol.js.map +1 -0
  266. package/dist/live/hub.d.ts +185 -0
  267. package/dist/live/hub.d.ts.map +1 -0
  268. package/dist/live/hub.js +544 -0
  269. package/dist/live/hub.js.map +1 -0
  270. package/dist/live/keyring.d.ts +92 -0
  271. package/dist/live/keyring.d.ts.map +1 -0
  272. package/dist/live/keyring.js +265 -0
  273. package/dist/live/keyring.js.map +1 -0
  274. package/dist/live/read-capped.d.ts +6 -0
  275. package/dist/live/read-capped.d.ts.map +1 -0
  276. package/dist/live/read-capped.js +42 -0
  277. package/dist/live/read-capped.js.map +1 -0
  278. package/dist/live/revocations.d.ts +52 -0
  279. package/dist/live/revocations.d.ts.map +1 -0
  280. package/dist/live/revocations.js +116 -0
  281. package/dist/live/revocations.js.map +1 -0
  282. package/dist/live/settings.d.ts +14 -0
  283. package/dist/live/settings.d.ts.map +1 -0
  284. package/dist/live/settings.js +38 -0
  285. package/dist/live/settings.js.map +1 -0
  286. package/dist/live-client.d.ts +34 -0
  287. package/dist/live-client.d.ts.map +1 -0
  288. package/dist/live-client.js +878 -0
  289. package/dist/live-client.js.map +1 -0
  290. package/dist/live-hub.d.ts +130 -0
  291. package/dist/live-hub.d.ts.map +1 -0
  292. package/dist/live-hub.js +507 -0
  293. package/dist/live-hub.js.map +1 -0
  294. package/dist/live-redis.d.ts +60 -0
  295. package/dist/live-redis.d.ts.map +1 -0
  296. package/dist/live-redis.js +212 -0
  297. package/dist/live-redis.js.map +1 -0
  298. package/dist/live.d.ts +150 -0
  299. package/dist/live.d.ts.map +1 -0
  300. package/dist/live.js +972 -0
  301. package/dist/live.js.map +1 -0
  302. package/dist/manifest.d.ts +113 -2
  303. package/dist/manifest.d.ts.map +1 -1
  304. package/dist/manifest.js +109 -5
  305. package/dist/manifest.js.map +1 -1
  306. package/dist/navigation.d.ts +9 -3
  307. package/dist/navigation.d.ts.map +1 -1
  308. package/dist/navigation.js +13 -3
  309. package/dist/navigation.js.map +1 -1
  310. package/dist/plugins/vite-zone-guard.d.ts +6 -0
  311. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  312. package/dist/plugins/vite-zone-guard.js +8 -2
  313. package/dist/plugins/vite-zone-guard.js.map +1 -1
  314. package/dist/ppr.d.ts +73 -0
  315. package/dist/ppr.d.ts.map +1 -0
  316. package/dist/ppr.js +76 -0
  317. package/dist/ppr.js.map +1 -0
  318. package/dist/query.d.ts +8 -13
  319. package/dist/query.d.ts.map +1 -1
  320. package/dist/query.js +112 -18
  321. package/dist/query.js.map +1 -1
  322. package/dist/render-seam.d.ts +32 -0
  323. package/dist/render-seam.d.ts.map +1 -1
  324. package/dist/render-seam.js +7 -0
  325. package/dist/render-seam.js.map +1 -1
  326. package/dist/route-types.d.ts.map +1 -1
  327. package/dist/route-types.js +2 -2
  328. package/dist/route-types.js.map +1 -1
  329. package/dist/router.d.ts +72 -4
  330. package/dist/router.d.ts.map +1 -1
  331. package/dist/router.js +416 -129
  332. package/dist/router.js.map +1 -1
  333. package/dist/server-components/build.d.ts.map +1 -1
  334. package/dist/server-components/build.js +3 -3
  335. package/dist/server-components/build.js.map +1 -1
  336. package/dist/server-components/client.d.ts +23 -1
  337. package/dist/server-components/client.d.ts.map +1 -1
  338. package/dist/server-components/client.js +92 -5
  339. package/dist/server-components/client.js.map +1 -1
  340. package/dist/server-components/definition.d.ts.map +1 -1
  341. package/dist/server-components/definition.js +2 -1
  342. package/dist/server-components/definition.js.map +1 -1
  343. package/dist/server-components/hole.d.ts +8 -0
  344. package/dist/server-components/hole.d.ts.map +1 -0
  345. package/dist/server-components/hole.js +20 -0
  346. package/dist/server-components/hole.js.map +1 -0
  347. package/dist/server-components/html-policy.d.ts.map +1 -1
  348. package/dist/server-components/html-policy.js +62 -4
  349. package/dist/server-components/html-policy.js.map +1 -1
  350. package/dist/server-components/html-scan.d.ts +17 -0
  351. package/dist/server-components/html-scan.d.ts.map +1 -0
  352. package/dist/server-components/html-scan.js +180 -0
  353. package/dist/server-components/html-scan.js.map +1 -0
  354. package/dist/server-components/html.d.ts.map +1 -1
  355. package/dist/server-components/html.js +19 -4
  356. package/dist/server-components/html.js.map +1 -1
  357. package/dist/server-components/router-integration.d.ts +4 -1
  358. package/dist/server-components/router-integration.d.ts.map +1 -1
  359. package/dist/server-components/router-integration.js +23 -2
  360. package/dist/server-components/router-integration.js.map +1 -1
  361. package/dist/server-components/runtime.d.ts +6 -1
  362. package/dist/server-components/runtime.d.ts.map +1 -1
  363. package/dist/server-components/runtime.js +88 -29
  364. package/dist/server-components/runtime.js.map +1 -1
  365. package/dist/server-components/state.d.ts +3 -1
  366. package/dist/server-components/state.d.ts.map +1 -1
  367. package/dist/server-components/state.js +50 -38
  368. package/dist/server-components/state.js.map +1 -1
  369. package/dist/server-components/types.d.ts +9 -0
  370. package/dist/server-components/types.d.ts.map +1 -1
  371. package/dist/server-components/vite.d.ts.map +1 -1
  372. package/dist/server-components/vite.js +26 -9
  373. package/dist/server-components/vite.js.map +1 -1
  374. package/dist/vite.d.ts +5 -1
  375. package/dist/vite.d.ts.map +1 -1
  376. package/dist/vite.js +58 -4
  377. package/dist/vite.js.map +1 -1
  378. package/package.json +48 -8
  379. package/src/boundary.ts +77 -5
  380. package/src/build-plan.ts +3 -0
  381. package/src/build-vite.ts +2 -0
  382. package/src/build.ts +20 -1
  383. package/src/client.ts +183 -23
  384. package/src/csp.ts +16 -2
  385. package/src/deferred.ts +11 -5
  386. package/src/dev-feed.ts +11 -2
  387. package/src/dev-session.ts +22 -11
  388. package/src/dev.ts +29 -8
  389. package/src/diagnostic-catalog.ts +53 -0
  390. package/src/diagnostic.ts +1 -0
  391. package/src/fn.ts +24 -7
  392. package/src/fs.ts +22 -8
  393. package/src/identity.ts +61 -0
  394. package/src/index.ts +20 -4
  395. package/src/internal/cdn/cloudflare.ts +2 -1
  396. package/src/internal/certified-components.ts +1 -1
  397. package/src/internal/codegen.ts +95 -9
  398. package/src/internal/dev-websocket.ts +230 -0
  399. package/src/internal/hole-runtime.ts +286 -0
  400. package/src/internal/identity-epoch.ts +239 -0
  401. package/src/internal/invalidation-contract.ts +35 -0
  402. package/src/internal/invalidation.ts +213 -0
  403. package/src/internal/island-bindings.ts +191 -0
  404. package/src/internal/island-list.ts +92 -0
  405. package/src/internal/island-mount.ts +68 -0
  406. package/src/internal/island-owner.ts +20 -0
  407. package/src/internal/island-resource.ts +76 -0
  408. package/src/internal/island-signals.ts +249 -0
  409. package/src/internal/keep-alive-table.ts +72 -0
  410. package/src/internal/live-contract.ts +111 -0
  411. package/src/internal/live-queries.ts +60 -0
  412. package/src/internal/page-events.ts +27 -0
  413. package/src/internal/page-execution.ts +605 -45
  414. package/src/internal/parity.ts +35 -6
  415. package/src/internal/ppr/admission.ts +300 -0
  416. package/src/internal/ppr/canary.ts +122 -0
  417. package/src/internal/ppr/compile-shell.ts +379 -0
  418. package/src/internal/ppr/contract.ts +265 -0
  419. package/src/internal/ppr/frames.ts +170 -0
  420. package/src/internal/ppr/integration.ts +137 -0
  421. package/src/internal/ppr/options.ts +221 -0
  422. package/src/internal/ppr/route-rules.ts +77 -0
  423. package/src/internal/ppr/runtime.ts +39 -0
  424. package/src/internal/ppr/server.ts +1766 -0
  425. package/src/internal/ppr/shell-store.ts +152 -0
  426. package/src/internal/prerendered-pages.ts +7 -4
  427. package/src/internal/present.ts +20 -0
  428. package/src/internal/query-digest.ts +34 -0
  429. package/src/internal/query-runtime.ts +14 -1
  430. package/src/internal/query-seam.ts +48 -0
  431. package/src/internal/render-document.ts +158 -113
  432. package/src/internal/request-abort.ts +26 -0
  433. package/src/internal/server-boundary.ts +10 -1
  434. package/src/internal/share-data.ts +136 -0
  435. package/src/internal/status-page.ts +95 -0
  436. package/src/internal/store-signature.ts +149 -0
  437. package/src/internal/vite-plugin-compat.ts +2 -2
  438. package/src/internal/web-app.ts +123 -3
  439. package/src/islands-swap.ts +283 -0
  440. package/src/islands.ts +169 -72
  441. package/src/isr.ts +348 -106
  442. package/src/keep-alive.ts +1354 -0
  443. package/src/live/bus.ts +104 -0
  444. package/src/live/core.ts +52 -0
  445. package/src/live/history.ts +109 -0
  446. package/src/live/hub-protocol.ts +133 -0
  447. package/src/live/hub.ts +620 -0
  448. package/src/live/keyring.ts +384 -0
  449. package/src/live/read-capped.ts +36 -0
  450. package/src/live/revocations.ts +155 -0
  451. package/src/live/settings.ts +46 -0
  452. package/src/live-client.ts +1011 -0
  453. package/src/live-hub.ts +658 -0
  454. package/src/live-redis.ts +284 -0
  455. package/src/live.ts +1257 -0
  456. package/src/manifest.ts +274 -6
  457. package/src/navigation.ts +14 -2
  458. package/src/plugins/vite-zone-guard.ts +15 -2
  459. package/src/ppr.ts +153 -0
  460. package/src/query.ts +147 -20
  461. package/src/render-seam.ts +37 -0
  462. package/src/route-types.ts +3 -1
  463. package/src/router.ts +534 -132
  464. package/src/server-components/build.ts +9 -3
  465. package/src/server-components/client.ts +89 -5
  466. package/src/server-components/definition.ts +2 -1
  467. package/src/server-components/hole.ts +23 -0
  468. package/src/server-components/html-policy.ts +63 -4
  469. package/src/server-components/html-scan.ts +159 -0
  470. package/src/server-components/html.ts +18 -4
  471. package/src/server-components/router-integration.ts +21 -2
  472. package/src/server-components/runtime.ts +114 -31
  473. package/src/server-components/state.ts +66 -44
  474. package/src/server-components/types.ts +16 -0
  475. package/src/server-components/vite.ts +33 -14
  476. package/src/vite.ts +75 -6
  477. package/dist/nano.d.ts +0 -125
  478. package/dist/nano.d.ts.map +0 -1
  479. package/dist/nano.js +0 -190
  480. package/dist/nano.js.map +0 -1
  481. package/src/nano.ts +0 -246
@@ -0,0 +1,1354 @@
1
+ /**
2
+ * @nifrajs/web/keep-alive - pages that stay mounted after the visitor leaves them, so back and forward
3
+ * show them again as they were left. The generated client entry imports this only when a route
4
+ * exports `keepAlive`, so no other app's client chunks contain it.
5
+ *
6
+ * {@link keepAliveClient} layers over the router. The page on screen is kept when a push or a
7
+ * back/forward traversal leaves it (at most three, each for at most ten minutes, all dropped when the
8
+ * visitor signs in or out); a traversal back to a kept page's own history entry publishes that page at
9
+ * once, then reloads its data in place. Adapters render the active page and every kept one from
10
+ * {@link slotTree}, and {@link createSlotParking} takes the hidden ones out of the document.
11
+ */
12
+ import { epochOf, IDENTITY_CHANNEL, onIdentityChange } from "./identity.ts"
13
+ import type { KeepAliveTable } from "./internal/keep-alive-table.ts"
14
+ import { PAGE_HIDE_EVENT, PAGE_SHOW_EVENT, releasePage } from "./internal/page-events.ts"
15
+ import { shareData } from "./internal/share-data.ts"
16
+ import type { ClientRouteHooks, KeepAliveOptions } from "./manifest.ts"
17
+ import type { MatchChain } from "./render-seam.ts"
18
+ import {
19
+ type ClientRouter,
20
+ type Fetcher,
21
+ type FetchRouteData,
22
+ fetchRouteData,
23
+ isRouteDataEnvelope,
24
+ type KeptPage,
25
+ type RouterState,
26
+ } from "./router.ts"
27
+
28
+ export { EPOCH_COOKIE, IDENTITY_CHANNEL, identityChanged } from "./identity.ts"
29
+ export type { KeepAliveTable } from "./internal/keep-alive-table.ts"
30
+ export { PAGE_HIDE_EVENT, PAGE_SHOW_EVENT } from "./internal/page-events.ts"
31
+
32
+ /** Most pages kept per tab. */
33
+ export const KEEP_ALIVE_MAX = 3
34
+ /** Longest a page stays hidden unless its route sets `keepAlive: { maxAge }`, in milliseconds. */
35
+ export const KEEP_ALIVE_MAX_AGE_MS = 10 * 60 * 1000
36
+
37
+ /** Why kept pages were dropped. */
38
+ export type KeepAliveDropReason =
39
+ /** The visitor signed in or out (the epoch cookie changed, or another tab said so). */
40
+ | "identity"
41
+ /** The document entered the browser's own back/forward cache. */
42
+ | "pagehide"
43
+ /** A push cut off the history entries they belonged to. */
44
+ | "forward"
45
+ /** A fourth page was kept; the least recently shown went. */
46
+ | "lru"
47
+ /** Hidden longer than its route's `maxAge`. */
48
+ | "expired"
49
+ /** Its chain leaves the page on screen at a layout rendered without slot wrappers. */
50
+ | "layout"
51
+ /** A route module was replaced by a hot update. */
52
+ | "hot-update"
53
+ /** `dropKept` was called. */
54
+ | "manual"
55
+
56
+ /** Per-navigation options of {@link KeepAliveRouter.navigate}. */
57
+ export interface KeepAliveNavigateOptions {
58
+ /** The instance of the history entry a back/forward lands on. Read from `history.state` when absent. */
59
+ readonly traverse?: string
60
+ }
61
+
62
+ /** The router as {@link keepAliveClient} hands it to the history layer and the adapter. */
63
+ export interface KeepAliveRouter extends ClientRouter {
64
+ navigate: (path: string, options?: KeepAliveNavigateOptions) => Promise<void>
65
+ /** Drop every kept page. */
66
+ dropKept: (reason?: KeepAliveDropReason) => void
67
+ /** Run `listener` with each state before it is published: the DOM half reads what the next render
68
+ * hides or shows again while the page being left is still laid out. Returns its removal. */
69
+ beforePublish: (listener: (next: RouterState) => void) => () => void
70
+ }
71
+
72
+ export interface KeepAliveClientOptions {
73
+ /** Where the slot wrappers go (generated from the route table). */
74
+ readonly table: KeepAliveTable
75
+ /** Route id → its client hooks, filled in as route modules load. A page is kept only while its
76
+ * module exports `keepAlive`. */
77
+ readonly routeHooks: Readonly<Record<string, ClientRouteHooks | undefined>>
78
+ /** Route id → its layout ids then its own id, filled in as route modules load. */
79
+ readonly matchChains: Readonly<Record<string, MatchChain | undefined>>
80
+ /** The transport to layer over (a live app's); the router's own by default. */
81
+ readonly fetchData?: FetchRouteData
82
+ /** Most pages kept (default {@link KEEP_ALIVE_MAX}). */
83
+ readonly max?: number
84
+ /** Called with the instances each drop removed. */
85
+ readonly onDrop?: (reason: KeepAliveDropReason, instances: readonly string[]) => void
86
+ }
87
+
88
+ /** What {@link keepAliveClient} returns: the transport to build the router with, then the layer. */
89
+ export interface KeepAliveClient {
90
+ /** Pass as `createClientRouter({ fetchData })`, so a restored page's reload keeps what it showed. */
91
+ readonly fetchData: FetchRouteData
92
+ /** Layer keep-alive over the router built with {@link fetchData}. Call once, before
93
+ * `installHistory`. */
94
+ attach(router: ClientRouter): KeepAliveRouter
95
+ }
96
+
97
+ interface Entry {
98
+ readonly index: number
99
+ readonly instance: string | undefined
100
+ }
101
+
102
+ // The server data a client loader built a page's data from, by the path it was loaded under.
103
+ type Sources = ReadonlyMap<string, unknown> | undefined
104
+
105
+ interface Kept {
106
+ readonly page: KeptPage
107
+ readonly index: number
108
+ readonly epoch: string
109
+ readonly identity: number
110
+ readonly sources: Sources
111
+ readonly hiddenAt: number
112
+ readonly maxAge: number
113
+ readonly revalidate: boolean
114
+ stale: boolean
115
+ }
116
+
117
+ interface Active {
118
+ readonly instance: string
119
+ readonly index: number
120
+ readonly settled: RouterState
121
+ readonly epoch: string
122
+ readonly identity: number
123
+ readonly sources: Sources
124
+ }
125
+
126
+ interface Restore {
127
+ /** The URL of the history entry restored: the page's, or the target its boundary holds. */
128
+ readonly path: string
129
+ /** The URL the page's own data reloads from. */
130
+ readonly pagePath: string
131
+ readonly kept: RouterState
132
+ readonly reload: boolean
133
+ readonly sources: Sources
134
+ }
135
+
136
+ const pathnameOf = (path: string): string => {
137
+ const q = path.indexOf("?")
138
+ return q === -1 ? path : path.slice(0, q)
139
+ }
140
+ const pagePathOf = (state: RouterState): string => state.intercepted?.pagePath ?? state.path
141
+
142
+ // Unique per document, so an instance an entry carries from an earlier document never names one here.
143
+ const DOCUMENT = Math.random().toString(36).slice(2, 8)
144
+ let sequence = 0
145
+ const newInstance = (): string => `${++sequence}:${DOCUMENT}`
146
+ const ownInstance = (value: unknown): string | undefined =>
147
+ typeof value === "string" && value.endsWith(`:${DOCUMENT}`) ? value : undefined
148
+
149
+ const readEntry = (): Entry => {
150
+ const state: unknown = typeof history === "undefined" ? null : history.state
151
+ const fields = typeof state === "object" && state !== null ? state : {}
152
+ const index = Reflect.get(fields, "nifraIndex")
153
+ return {
154
+ index: typeof index === "number" ? index : 0,
155
+ instance: ownInstance(Reflect.get(fields, "nifraInstance")),
156
+ }
157
+ }
158
+ // The instance rides on the entry beside the history layer's own fields, which every write it makes
159
+ // to an existing entry keeps; a fresh push or replace carries none.
160
+ const stampEntry = (instance: string): void => {
161
+ if (typeof history === "undefined") return
162
+ const state: unknown = history.state
163
+ if (
164
+ typeof state === "object" &&
165
+ state !== null &&
166
+ Reflect.get(state, "nifraInstance") === instance
167
+ )
168
+ return
169
+ try {
170
+ history.replaceState(
171
+ Object.assign({}, typeof state === "object" ? state : null, { nifraInstance: instance }),
172
+ "",
173
+ )
174
+ } catch {}
175
+ }
176
+
177
+ const readEpoch = (): string => (typeof document === "undefined" ? "" : epochOf(document.cookie))
178
+ const now = (): number => Date.now()
179
+
180
+ /**
181
+ * Keep-alive for a router. See the module docs; the generated client entry wires it:
182
+ *
183
+ * ```ts
184
+ * const keep = keepAliveClient({ table, routeHooks, matchChains })
185
+ * const router = keep.attach(createClientRouter({ ..., fetchData: keep.fetchData }))
186
+ * installHistory(router)
187
+ * ```
188
+ */
189
+ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClient {
190
+ const { table, routeHooks, matchChains } = options
191
+ const base = options.fetchData ?? fetchRouteData
192
+ const max = options.max ?? KEEP_ALIVE_MAX
193
+ const keepRoutes = new Set(table.routes)
194
+ const wrappedLayouts = new Set(table.layouts)
195
+ // The restore whose reload the router is running, which the transport answers.
196
+ let reloading: Restore | undefined
197
+ const ownsLoader = (routeId: string | undefined): boolean =>
198
+ routeId !== undefined && routeHooks[routeId]?.clientLoader !== undefined
199
+ // What client-loader routes loaded since the page on screen settled. A restore that loads nothing
200
+ // runs the client loader over the kept page's own, as a navigation over prefetched data does.
201
+ const fresh = new Map<string, unknown>()
202
+ const noteSource = (path: string, payload: unknown): void => {
203
+ if (!isRouteDataEnvelope(payload)) fresh.set(path, payload)
204
+ else if (payload.status === undefined) fresh.set(path, payload.data)
205
+ }
206
+ const sourcesOf = (state: RouterState): Sources => {
207
+ const sources = new Map<string, unknown>()
208
+ for (const path of [pagePathOf(state), state.path])
209
+ if (fresh.has(path)) sources.set(path, fresh.get(path))
210
+ return sources.size === 0 ? undefined : sources
211
+ }
212
+
213
+ // A restored page's reload: its kept data, unrequested, when it needs no reload; otherwise the
214
+ // response shared onto the kept data, so what did not change keeps the identity it has on screen.
215
+ // A reload that fails shows the kept data instead of loading the page as a document.
216
+ const fetchData: FetchRouteData = async (
217
+ path,
218
+ match,
219
+ signal,
220
+ navigation,
221
+ purpose,
222
+ hosts,
223
+ read,
224
+ ) => {
225
+ const restore = reloading
226
+ const held = restore?.kept.intercepted
227
+ // A page kept with a boundary open reloads twice: its own data, then the target the boundary holds.
228
+ const target =
229
+ held !== undefined && path === restore?.kept.path && path !== restore.pagePath
230
+ ? restore.kept.boundaries?.[held.boundary]
231
+ : undefined
232
+ if (restore === undefined || (path !== restore.pagePath && target === undefined)) {
233
+ if (purpose === "prefetch" || !ownsLoader(match.routeId))
234
+ return base(path, match, signal, navigation, purpose, hosts, read)
235
+ const payload = await base(path, match, signal, navigation, purpose, hosts, read)
236
+ noteSource(path, payload)
237
+ return payload
238
+ }
239
+ const sourced = restore.sources?.has(path) === true
240
+ const kept = (): Record<string, unknown> => {
241
+ const data = sourced ? restore.sources?.get(path) : target?.data
242
+ if (target !== undefined) return { v: 1, data, live: target.live }
243
+ const envelope = envelopeOf(restore.kept)
244
+ if (sourced) envelope.data = data
245
+ return envelope
246
+ }
247
+ // The page's hole hosts reload as fills, applied in place on the roots kept with it, even when
248
+ // the route keeps its own data.
249
+ const holes = target === undefined ? hostsOf(restore.kept) : []
250
+ if (!restore.reload && holes.length === 0) return kept()
251
+ let payload: unknown
252
+ try {
253
+ payload = await base(
254
+ path,
255
+ match,
256
+ signal,
257
+ navigation,
258
+ purpose,
259
+ holes.length === 0 ? hosts : holes,
260
+ read,
261
+ )
262
+ } catch (error) {
263
+ if (signal?.aborted === true || hasRedirect(error)) throw error
264
+ return kept()
265
+ }
266
+ if (!restore.reload) return fillsOnto(kept(), restore.kept, payload, holes)
267
+ if (ownsLoader(match.routeId)) noteSource(path, payload)
268
+ const hooks = routeHooks[target === undefined ? restore.kept.routeId : match.routeId]
269
+ // `read` itself answers a reload whose response text is what the path shows, which the router
270
+ // keeps unparsed.
271
+ if (payload === read || hooks?.shareData === false) return payload
272
+ // The router's transport records the text of a plain JSON response, a tree with no symbol keys.
273
+ const plainJson = read?.text !== undefined && hooks?.clientLoader === undefined
274
+ return target === undefined
275
+ ? sharedOnto(restore.kept, payload, plainJson)
276
+ : targetOnto(target.data, payload, plainJson)
277
+ }
278
+
279
+ const attach = (inner: ClientRouter): KeepAliveRouter => {
280
+ const records = new Map<string, Kept>()
281
+ const listeners = new Set<() => void>()
282
+ const before = new Set<(next: RouterState) => void>()
283
+ const wrappedFetchers = new Map<string, Fetcher>()
284
+ let keptPages: readonly KeptPage[] | undefined
285
+ let lastEpoch = readEpoch()
286
+ // Explicit identity signals also cover bearer auth, where the cookie never changes.
287
+ let identity = 0
288
+
289
+ const first = readEntry()
290
+ const initial = inner.snapshot()
291
+ // A client loader runs over the document's own data as the router hydrates.
292
+ if (ownsLoader(initial.routeId)) fresh.set(initial.path, initial.data)
293
+ // Where the page's data came from: `epoch` is the one it was loaded under (a page left after a
294
+ // sign-in or sign-out shows the previous visitor's data, so it is not kept), and `sources` what
295
+ // a client loader built it from.
296
+ let active: Active = {
297
+ instance: first.instance ?? newInstance(),
298
+ index: first.index,
299
+ settled: initial,
300
+ epoch: lastEpoch,
301
+ identity,
302
+ sources: sourcesOf(initial),
303
+ }
304
+ stampEntry(active.instance)
305
+ // Browser-only code reads `process.env.NODE_ENV` bare: every bundler replaces it, but none replaces
306
+ // `typeof process`, and a browser has no `process`, so that guard turned these off in Vite dev.
307
+ if (process.env.NODE_ENV !== "production")
308
+ Reflect.set(globalThis, KEPT_PAGES_GLOBAL, {
309
+ pages: () => describeKept(records.values()),
310
+ })
311
+ // What is on screen: the router's state, or a restored page's kept state until its reload lands.
312
+ let shown = active.settled
313
+ let flags: Partial<RouterState> | undefined
314
+ // The page being left stays mounted, hidden, while a `_loading` page takes its place.
315
+ let leaving = false
316
+ // The page being left is gone (dropped, or unmountable) before its navigation settled.
317
+ let leftGone = false
318
+
319
+ const keepAliveOf = (routeId: string): true | KeepAliveOptions | undefined =>
320
+ keepRoutes.has(routeId) ? routeHooks[routeId]?.keepAlive : undefined
321
+ // A boundary open over the page is kept with it (its target is the page's to show again).
322
+ const keepable = (state: RouterState): boolean =>
323
+ !state.pending && keepAliveOf(state.routeId) !== undefined
324
+ // One page instance per route, and per pathname on a route that keeps alive, whose pages would
325
+ // otherwise have nothing to keep.
326
+ const sameInstance = (a: RouterState, b: RouterState): boolean =>
327
+ a.routeId === b.routeId &&
328
+ (!keepRoutes.has(b.routeId) || pathnameOf(pagePathOf(a)) === pathnameOf(pagePathOf(b)))
329
+
330
+ const compose = (): RouterState => {
331
+ keptPages ??= [...records.values()].map((record) => record.page)
332
+ const kept = leaving
333
+ ? [...keptPages, { instance: active.instance, state: active.settled }]
334
+ : keptPages
335
+ return {
336
+ ...shown,
337
+ instance: leaving ? undefined : active.instance,
338
+ kept: kept.length === 0 ? undefined : kept,
339
+ ...flags,
340
+ }
341
+ }
342
+ let published = compose()
343
+
344
+ const drop = (instances: readonly string[], reason: KeepAliveDropReason): void => {
345
+ const gone = instances.filter((instance) => records.delete(instance))
346
+ if (gone.length === 0) return
347
+ keptPages = undefined
348
+ options.onDrop?.(reason, gone)
349
+ }
350
+ const refresh = (): void => {
351
+ let next = compose()
352
+ // A kept page whose chain leaves the page on screen at an unwrapped layout cannot be hidden.
353
+ const stuck = unplaceable(next, matchChains, keepRoutes, wrappedLayouts)
354
+ if (stuck.length > 0) {
355
+ if (leaving && stuck.includes(active.instance)) {
356
+ leaving = false
357
+ leftGone = true
358
+ }
359
+ drop(stuck, "layout")
360
+ next = compose()
361
+ }
362
+ for (const listener of [...before]) listener(next)
363
+ published = next
364
+ for (const listener of [...listeners]) listener()
365
+ }
366
+ const dropKept = (reason: KeepAliveDropReason = "manual"): void => {
367
+ const left = leaving || inner.snapshot().pending
368
+ if (records.size === 0 && !leaving) {
369
+ if (left) leftGone = true
370
+ return
371
+ }
372
+ if (left) {
373
+ leaving = false
374
+ leftGone = true
375
+ }
376
+ drop([...records.keys()], reason)
377
+ refresh()
378
+ }
379
+ const sweep = (): void => {
380
+ const at = now()
381
+ const expired: string[] = []
382
+ for (const [instance, record] of records)
383
+ if (at - record.hiddenAt > record.maxAge) expired.push(instance)
384
+ drop(expired, "expired")
385
+ }
386
+ // A response or another tab may carry a new epoch: what was kept belonged to the previous visitor.
387
+ const checkEpoch = (): void => {
388
+ const epoch = readEpoch()
389
+ if (epoch === lastEpoch) return
390
+ lastEpoch = epoch
391
+ channel?.postMessage("changed")
392
+ dropKept("identity")
393
+ }
394
+
395
+ const keep = ({
396
+ instance,
397
+ index,
398
+ settled: state,
399
+ epoch,
400
+ identity: loadedIdentity,
401
+ sources,
402
+ }: Active): void => {
403
+ if (!keepable(state) || epoch !== readEpoch() || loadedIdentity !== identity) return
404
+ const setting = keepAliveOf(state.routeId)
405
+ const settings = typeof setting === "object" ? setting : {}
406
+ records.delete(instance)
407
+ records.set(instance, {
408
+ page: { instance, state },
409
+ index,
410
+ epoch,
411
+ identity: loadedIdentity,
412
+ sources,
413
+ hiddenAt: now(),
414
+ maxAge: settings.maxAge ?? KEEP_ALIVE_MAX_AGE_MS,
415
+ revalidate: settings.revalidate !== false,
416
+ stale: false,
417
+ })
418
+ keptPages = undefined
419
+ if (records.size > max) drop([...records.keys()].slice(0, records.size - max), "lru")
420
+ }
421
+
422
+ let loading: { readonly identity: number; readonly epoch: string } | undefined
423
+
424
+ const settle = (next: RouterState): void => {
425
+ const entry = readEntry()
426
+ const loadedIdentity = loading?.identity ?? identity
427
+ const loadedEpoch = loading?.epoch ?? readEpoch()
428
+ loading = undefined
429
+ if (sameInstance(active.settled, next)) {
430
+ // Data the router did not load again is still the data it was loaded with.
431
+ const same = next.data === active.settled.data
432
+ active = {
433
+ instance: active.instance,
434
+ index: entry.index,
435
+ settled: next,
436
+ epoch: same ? active.epoch : loadedEpoch,
437
+ identity: same ? active.identity : loadedIdentity,
438
+ sources: same ? active.sources : sourcesOf(next),
439
+ }
440
+ } else {
441
+ // The page on screen was left: by a push (a fresh entry above it), a traversal (an entry that
442
+ // already carries an instance), or a replace (its own entry rewritten), which discards it.
443
+ if (entry.index > active.index && entry.instance === undefined) {
444
+ drop(
445
+ [...records].filter(([, record]) => record.index >= entry.index).map(([id]) => id),
446
+ "forward",
447
+ )
448
+ }
449
+ if (entry.index !== active.index && !leftGone) keep(active)
450
+ const reuse =
451
+ entry.index !== active.index &&
452
+ entry.instance !== undefined &&
453
+ entry.instance !== active.instance &&
454
+ !records.has(entry.instance)
455
+ ? entry.instance
456
+ : undefined
457
+ active = {
458
+ instance: reuse ?? newInstance(),
459
+ index: entry.index,
460
+ settled: next,
461
+ epoch: loadedEpoch,
462
+ identity: loadedIdentity,
463
+ sources: sourcesOf(next),
464
+ }
465
+ }
466
+ fresh.clear()
467
+ stampEntry(active.instance)
468
+ sweep()
469
+ shown = next
470
+ flags = undefined
471
+ leaving = false
472
+ leftGone = false
473
+ refresh()
474
+ checkEpoch()
475
+ }
476
+
477
+ let restoring:
478
+ | (Restore & { readonly done: () => void; readonly ended: Promise<void> })
479
+ | undefined
480
+ const endRestore = (): void => {
481
+ const restore = restoring
482
+ restoring = undefined
483
+ reloading = undefined
484
+ restore?.done()
485
+ }
486
+
487
+ inner.subscribe(() => {
488
+ const next = inner.snapshot()
489
+ const restore = restoring
490
+ if (restore !== undefined) {
491
+ // The restored page stays on screen while its reload runs: the router's own pending tick for
492
+ // it is not a navigation the visitor made. A redirect it follows is.
493
+ if (
494
+ next.pending &&
495
+ (next.pendingPath === undefined ||
496
+ next.pendingPath === restore.path ||
497
+ next.pendingPath === restore.pagePath)
498
+ )
499
+ return
500
+ const onPage =
501
+ !next.pending &&
502
+ pagePathOf(next) === restore.pagePath &&
503
+ next.routeId === restore.kept.routeId
504
+ // The page alone, on the way to opening its boundary on the target again.
505
+ if (onPage && restore.kept.intercepted !== undefined && next.intercepted === undefined)
506
+ return
507
+ endRestore()
508
+ if (onPage) {
509
+ active = {
510
+ instance: active.instance,
511
+ index: active.index,
512
+ settled: next,
513
+ // The epoch and identity the restore started under: a reload's response, or the kept
514
+ // response a failed reload falls back to, never belongs to a visitor who arrived since.
515
+ epoch: active.epoch,
516
+ identity: active.identity,
517
+ // A reload that failed shows the kept data, from the kept sources.
518
+ sources: sourcesOf(next) ?? active.sources,
519
+ }
520
+ fresh.clear()
521
+ shown = { ...next, params: shareData(restore.kept.params, next.params) }
522
+ flags = undefined
523
+ refresh()
524
+ checkEpoch()
525
+ return
526
+ }
527
+ }
528
+ if (!next.pending) {
529
+ settle(next)
530
+ return
531
+ }
532
+ // A response belongs to the identity that started its load, not the visitor at arrival.
533
+ loading ??= { identity, epoch: readEpoch() }
534
+ shown = next
535
+ flags = undefined
536
+ leaving = next.routeId !== active.settled.routeId && !leftGone && keepable(active.settled)
537
+ refresh()
538
+ })
539
+
540
+ const restore = (path: string, instance: string, record: Kept): Promise<void> => {
541
+ loading = undefined
542
+ const entry = readEntry()
543
+ records.delete(instance)
544
+ keptPages = undefined
545
+ if (!leftGone) keep(active)
546
+ const kept = record.page.state
547
+ const { sources } = record
548
+ active = {
549
+ instance,
550
+ index: entry.index,
551
+ settled: kept,
552
+ epoch: record.epoch,
553
+ identity: record.identity,
554
+ sources,
555
+ }
556
+ fresh.clear()
557
+ // Data a client loader built is shown again only from what it was built from.
558
+ const unsourced =
559
+ (ownsLoader(kept.routeId) && sources?.has(pagePathOf(kept)) !== true) ||
560
+ (kept.intercepted !== undefined &&
561
+ ownsLoader(inner.match(kept.path)?.routeId) &&
562
+ sources?.has(kept.path) !== true)
563
+ const reload = record.revalidate || record.stale || kept.path !== path || unsourced
564
+ const modal = kept.intercepted
565
+ const pagePath = modal === undefined ? path : modal.pagePath
566
+ let done = (): void => {}
567
+ const ended = new Promise<void>((resolve) => {
568
+ done = resolve
569
+ })
570
+ endRestore()
571
+ const mine = { path, pagePath, kept, reload, sources, done, ended }
572
+ restoring = mine
573
+ reloading = { path, pagePath, kept, reload, sources }
574
+ stampEntry(instance)
575
+ shown = kept
576
+ flags = { restored: true, revalidating: reload }
577
+ leaving = false
578
+ leftGone = false
579
+ refresh()
580
+ // The router shows a boundary's target only over the page it opened on: it loads the page, then
581
+ // opens the boundary on the target again, as the link that first opened it did.
582
+ const reloaded =
583
+ modal === undefined
584
+ ? inner.navigate(path)
585
+ : inner.navigate(pagePath).then(() => {
586
+ const now = inner.snapshot()
587
+ const onPage =
588
+ restoring === mine &&
589
+ !now.pending &&
590
+ now.routeId === kept.routeId &&
591
+ pagePathOf(now) === pagePath
592
+ return onPage ? inner.navigate(path) : undefined
593
+ })
594
+ return reloaded.finally(() => {
595
+ if (restoring === mine) endRestore()
596
+ })
597
+ }
598
+
599
+ const navigate = (path: string, navOptions?: KeepAliveNavigateOptions): Promise<void> => {
600
+ sweep()
601
+ const instance = navOptions?.traverse ?? readEntry().instance
602
+ const record =
603
+ instance === undefined || instance === active.instance ? undefined : records.get(instance)
604
+ if (instance !== undefined && record !== undefined) {
605
+ if (record.epoch !== readEpoch()) dropKept("identity")
606
+ else if (pathnameOf(record.page.state.path) === pathnameOf(path))
607
+ return restore(path, instance, record)
608
+ }
609
+ endRestore()
610
+ loading = { identity, epoch: readEpoch() }
611
+ return inner.navigate(path)
612
+ }
613
+
614
+ // A submit or an invalidation may change what a hidden page shows; the response that says which
615
+ // paths (`X-Nifra-Revalidate`) is read inside the router, so every kept page reloads when shown.
616
+ const markStale = (paths?: readonly string[]): void => {
617
+ for (const record of records.values()) {
618
+ const { state } = record.page
619
+ if (paths === undefined || paths.includes(pagePathOf(state)) || paths.includes(state.path))
620
+ record.stale = true
621
+ }
622
+ }
623
+ // A submit or an invalidation acts on the page on screen, which a restore hands to the router only
624
+ // once its reload settles.
625
+ const afterRestore = async (): Promise<void> => {
626
+ while (restoring !== undefined) await restoring.ended
627
+ }
628
+
629
+ const channel =
630
+ typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(IDENTITY_CHANNEL)
631
+ const identityDrop = (): void => {
632
+ lastEpoch = readEpoch()
633
+ identity++
634
+ dropKept("identity")
635
+ }
636
+ channel?.addEventListener("message", identityDrop)
637
+ onIdentityChange(identityDrop)
638
+ if (typeof addEventListener === "function") {
639
+ addEventListener("pagehide", (event) => {
640
+ if ("persisted" in event && event.persisted === true) dropKept("pagehide")
641
+ })
642
+ addEventListener("visibilitychange", () => {
643
+ if (document.visibilityState === "visible") checkEpoch()
644
+ })
645
+ }
646
+
647
+ return {
648
+ snapshot: () => published,
649
+ subscribe: (listener) => {
650
+ listeners.add(listener)
651
+ return () => {
652
+ listeners.delete(listener)
653
+ }
654
+ },
655
+ navigate,
656
+ submit: async (action, body, submitOptions) => {
657
+ await afterRestore()
658
+ try {
659
+ await inner.submit(action, body, submitOptions)
660
+ } finally {
661
+ markStale()
662
+ checkEpoch()
663
+ }
664
+ },
665
+ hydrate: () => inner.hydrate(),
666
+ invalidate: (paths) => {
667
+ markStale(paths)
668
+ // At once when nothing is restoring: the live client names its load around this call.
669
+ const invalidate = (): Promise<void> => {
670
+ const capture = { identity, epoch: readEpoch() }
671
+ loading = capture
672
+ return inner.invalidate(paths).finally(() => {
673
+ if (loading === capture) loading = undefined
674
+ })
675
+ }
676
+ return restoring === undefined ? invalidate() : afterRestore().then(invalidate)
677
+ },
678
+ prefetch: (path) => inner.prefetch(path),
679
+ fetcher: (key) => {
680
+ let fetcher = wrappedFetchers.get(key)
681
+ if (fetcher === undefined) {
682
+ const own = inner.fetcher(key)
683
+ fetcher = {
684
+ snapshot: own.snapshot,
685
+ subscribe: own.subscribe,
686
+ load: own.load,
687
+ submit: async (action, body) => {
688
+ try {
689
+ await own.submit(action, body)
690
+ } finally {
691
+ markStale()
692
+ checkEpoch()
693
+ }
694
+ },
695
+ }
696
+ wrappedFetchers.set(key, fetcher)
697
+ }
698
+ return fetcher
699
+ },
700
+ fetchers: () => inner.fetchers(),
701
+ subscribeFetchers: (listener) => inner.subscribeFetchers(listener),
702
+ match: (path) => inner.match(path),
703
+ dropKept,
704
+ beforePublish: (listener) => {
705
+ before.add(listener)
706
+ return () => {
707
+ before.delete(listener)
708
+ }
709
+ },
710
+ }
711
+ }
712
+
713
+ return { fetchData, attach }
714
+ }
715
+
716
+ const hasRedirect = (error: unknown): boolean =>
717
+ typeof error === "object" &&
718
+ error !== null &&
719
+ typeof Reflect.get(error, "redirectTo") === "string"
720
+
721
+ /** A held target's reload, its data shared onto what the boundary shows. */
722
+ const targetOnto = (shown: unknown, payload: unknown, plainJson: boolean): unknown => {
723
+ if (!isRouteDataEnvelope(payload)) return shareData(shown, payload, plainJson)
724
+ if (payload.status !== undefined) return payload
725
+ return { ...payload, data: shareData(shown, payload.data, plainJson) }
726
+ }
727
+
728
+ /** The holes of `state`'s page that are hosts a partially prerendered document left open. */
729
+ const hostsOf = (state: RouterState): string[] =>
730
+ Object.values(state.boundaries ?? {})
731
+ .filter((held) => held.ppr === true)
732
+ .map((held) => held.name)
733
+
734
+ /** `envelope`, what `kept` shows, with the fills `payload` carries for its hole hosts `holes` and
735
+ * nothing else of it. */
736
+ const fillsOnto = (
737
+ envelope: Record<string, unknown>,
738
+ kept: RouterState,
739
+ payload: unknown,
740
+ holes: readonly string[],
741
+ ): unknown => {
742
+ if (!isRouteDataEnvelope(payload) || payload.status !== undefined) return envelope
743
+ const boundaries = { ...kept.boundaries }
744
+ for (const name of holes) {
745
+ const fill = payload.boundaries?.[name]
746
+ if (fill?.ppr === true) boundaries[name] = fill
747
+ }
748
+ return { ...envelope, boundaries }
749
+ }
750
+
751
+ /** A route data envelope that publishes exactly `state`'s data. */
752
+ const envelopeOf = (state: RouterState): Record<string, unknown> => {
753
+ const envelope: Record<string, unknown> = {
754
+ v: 1,
755
+ data: state.data,
756
+ layoutData: state.layoutData,
757
+ serverComponents: state.serverComponents,
758
+ live: state.live,
759
+ }
760
+ if (state.boundaries !== undefined) envelope.boundaries = state.boundaries
761
+ return envelope
762
+ }
763
+
764
+ /** `payload` with every part equal to what `kept` shows replaced by that part. A layout the server
765
+ * skipped (`retained`) takes the value on screen, as on any navigation. */
766
+ const sharedOnto = (kept: RouterState, payload: unknown, plainJson: boolean): unknown => {
767
+ if (!isRouteDataEnvelope(payload)) return shareData(kept.data, payload, plainJson)
768
+ if (payload.status !== undefined) return payload
769
+ const retained = new Set(payload.retained ?? [])
770
+ return {
771
+ ...payload,
772
+ data: shareData(kept.data, payload.data, plainJson),
773
+ layoutData: payload.layoutData?.map((value, index) =>
774
+ retained.has(index) ? value : shareData(kept.layoutData?.[index], value, plainJson),
775
+ ),
776
+ ...(payload.boundaries === undefined
777
+ ? {}
778
+ : { boundaries: shareData(kept.boundaries, payload.boundaries, plainJson) }),
779
+ serverComponents: shareData(kept.serverComponents, payload.serverComponents, plainJson),
780
+ }
781
+ }
782
+
783
+ /** One element of the view: a layout and the slots under it, or a page. */
784
+ export interface SlotNode {
785
+ /** Unique in the tree: a layout's id, or `\0` and a page instance. */
786
+ readonly key: string
787
+ /** The chain index of the element it renders: a layout's depth, or the page's (its route's layout
788
+ * count), from where the page's own chain (an `_error` boundary, then the page) continues. */
789
+ readonly depth: number
790
+ /** The state it renders from: the page on screen's while it is on the path to it, else the most
791
+ * recently shown kept page under it. */
792
+ readonly state: RouterState
793
+ /** Not on the path to the page on screen. */
794
+ readonly hidden: boolean
795
+ /** Rendered inside the two slot wrappers, so it can be taken out of the document. */
796
+ readonly wrapped: boolean
797
+ /** The slots under a layout, in the order their pages were first shown. Absent on a page. */
798
+ readonly children?: readonly SlotNode[]
799
+ }
800
+
801
+ interface Member {
802
+ readonly key: string
803
+ readonly order: number
804
+ readonly recency: number
805
+ readonly state: RouterState
806
+ readonly hidden: boolean
807
+ readonly layouts: readonly string[]
808
+ readonly leaf: string
809
+ }
810
+
811
+ const NO_LAYOUTS: readonly string[] = []
812
+ // The `_loading` page shown while a navigation runs has no instance of its own.
813
+ const LOADING = "\0loading"
814
+
815
+ const memberOf = (
816
+ instance: string | undefined,
817
+ state: RouterState,
818
+ hidden: boolean,
819
+ recency: number,
820
+ chains: Readonly<Record<string, MatchChain | undefined>>,
821
+ ): Member => {
822
+ const ids = chains[state.routeId]?.ids
823
+ const order = instance === undefined ? Number.MAX_SAFE_INTEGER : Number.parseInt(instance, 10)
824
+ return {
825
+ key: instance === undefined ? LOADING : `\0${instance}`,
826
+ order: Number.isNaN(order) ? Number.MAX_SAFE_INTEGER : order,
827
+ recency,
828
+ state,
829
+ hidden,
830
+ layouts: ids === undefined ? NO_LAYOUTS : ids.slice(0, -1),
831
+ leaf: ids?.at(-1) ?? state.routeId,
832
+ }
833
+ }
834
+
835
+ const membersOf = (
836
+ state: RouterState,
837
+ chains: Readonly<Record<string, MatchChain | undefined>>,
838
+ ): Member[] => {
839
+ const kept = state.kept ?? []
840
+ const members = kept.map((page, i) => memberOf(page.instance, page.state, true, i, chains))
841
+ members.push(memberOf(state.instance, state, false, kept.length, chains))
842
+ return members.sort((a, b) => a.order - b.order || (a.key < b.key ? -1 : 1))
843
+ }
844
+
845
+ const levelOf = (
846
+ members: readonly Member[],
847
+ depth: number,
848
+ pages: ReadonlySet<string>,
849
+ layouts: ReadonlySet<string>,
850
+ previous: ReadonlyMap<string, SlotNode>,
851
+ ): SlotNode[] => {
852
+ const groups = new Map<string, Member[]>()
853
+ for (const member of members) {
854
+ const key = member.layouts[depth] ?? member.key
855
+ const group = groups.get(key)
856
+ if (group === undefined) groups.set(key, [member])
857
+ else group.push(member)
858
+ }
859
+ const nodes: SlotNode[] = []
860
+ for (const [key, group] of groups) {
861
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a group is created with its first member
862
+ const head = group[0] as Member
863
+ if (key === head.key) {
864
+ nodes.push(
865
+ reuse(previous, {
866
+ key,
867
+ depth,
868
+ state: head.state,
869
+ hidden: head.hidden,
870
+ wrapped: pages.has(head.leaf),
871
+ }),
872
+ )
873
+ continue
874
+ }
875
+ let shown = head
876
+ for (const member of group) {
877
+ if (!member.hidden) {
878
+ shown = member
879
+ break
880
+ }
881
+ if (member.recency > shown.recency) shown = member
882
+ }
883
+ nodes.push(
884
+ reuse(previous, {
885
+ key,
886
+ depth,
887
+ state: shown.state,
888
+ hidden: shown.hidden,
889
+ wrapped: layouts.has(key),
890
+ children: levelOf(group, depth + 1, pages, layouts, previous),
891
+ }),
892
+ )
893
+ }
894
+ return nodes
895
+ }
896
+
897
+ // The previous tree's node when nothing about it changed, so a renderer that memoizes on the node
898
+ // skips it: a hidden page never re-renders while it is hidden.
899
+ const reuse = (previous: ReadonlyMap<string, SlotNode>, node: SlotNode): SlotNode => {
900
+ const old = previous.get(node.key)
901
+ if (
902
+ old === undefined ||
903
+ old.depth !== node.depth ||
904
+ old.state !== node.state ||
905
+ old.hidden !== node.hidden ||
906
+ old.wrapped !== node.wrapped
907
+ )
908
+ return node
909
+ const a = old.children
910
+ const b = node.children
911
+ if (a === b) return old
912
+ if (a === undefined || b === undefined || a.length !== b.length) return node
913
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return node
914
+ return old
915
+ }
916
+
917
+ const indexTree = (nodes: readonly SlotNode[], into: Map<string, SlotNode>): void => {
918
+ for (const node of nodes) {
919
+ into.set(node.key, node)
920
+ if (node.children !== undefined) indexTree(node.children, into)
921
+ }
922
+ }
923
+
924
+ /**
925
+ * The view of `state`: the page on screen and every kept page, grouped by the layouts they share. A
926
+ * layout every page under it shares renders once; where two chains part, each part is a slot. Pass
927
+ * the previous tree to keep the identity of every node that did not change.
928
+ */
929
+ export function slotTree(
930
+ state: RouterState,
931
+ chains: Readonly<Record<string, MatchChain | undefined>>,
932
+ table: KeepAliveTable,
933
+ previous?: readonly SlotNode[],
934
+ ): readonly SlotNode[] {
935
+ const before = new Map<string, SlotNode>()
936
+ if (previous !== undefined) indexTree(previous, before)
937
+ return levelOf(membersOf(state, chains), 0, new Set(table.routes), new Set(table.layouts), before)
938
+ }
939
+
940
+ // The kept pages a render of `state` could not hide: their chain leaves the page on screen at a
941
+ // layout rendered without slot wrappers.
942
+ const unplaceable = (
943
+ state: RouterState,
944
+ chains: Readonly<Record<string, MatchChain | undefined>>,
945
+ pages: ReadonlySet<string>,
946
+ layouts: ReadonlySet<string>,
947
+ ): string[] => {
948
+ if (state.kept === undefined) return []
949
+ const stuck: string[] = []
950
+ const visit = (nodes: readonly SlotNode[], parentHidden: boolean): void => {
951
+ for (const node of nodes) {
952
+ if (node.hidden && !parentHidden && !node.wrapped) collect(node, stuck)
953
+ else if (node.children !== undefined) visit(node.children, node.hidden)
954
+ }
955
+ }
956
+ visit(levelOf(membersOf(state, chains), 0, pages, layouts, new Map()), false)
957
+ return stuck
958
+ }
959
+
960
+ const collect = (node: SlotNode, into: string[]): void => {
961
+ if (node.children === undefined) {
962
+ if (node.key !== LOADING) into.push(node.key.slice(1))
963
+ return
964
+ }
965
+ for (const child of node.children) collect(child, into)
966
+ }
967
+
968
+ /** The DOM half of keep-alive: takes hidden slots out of the document and puts them back. */
969
+ export interface SlotParking {
970
+ /** The ref of a slot's outer wrapper (`data-nifra-entry`). Stable per key. */
971
+ entry(key: string): (element: Element | null) => void
972
+ /** The ref of a slot's inner wrapper (`data-nifra-page`), the element that is parked. Stable per key. */
973
+ page(key: string): (element: Element | null) => void
974
+ /** Before the render of `next`: put back what it shows again, and note the inner scroll offsets and
975
+ * focus of what it hides while that is still laid out (React hides it during the commit). */
976
+ before(next: readonly SlotNode[]): void
977
+ /** After the render of `tree` committed: park what it hides, restore what it shows again. */
978
+ after(tree: readonly SlotNode[]): void
979
+ }
980
+
981
+ interface Parked {
982
+ entry?: Element | undefined
983
+ page?: Element | undefined
984
+ parked: boolean
985
+ shown: boolean
986
+ showing: boolean
987
+ /** The hide event went out and no show event since. */
988
+ hid: boolean
989
+ next: Node | null
990
+ offsets: (readonly [Element, number, number])[] | undefined
991
+ focus: Element | undefined
992
+ checked: boolean
993
+ }
994
+
995
+ const scrollOffsets = (page: Element): (readonly [Element, number, number])[] => {
996
+ const offsets: (readonly [Element, number, number])[] = []
997
+ if (page.scrollTop !== 0 || page.scrollLeft !== 0)
998
+ offsets.push([page, page.scrollTop, page.scrollLeft])
999
+ for (const element of page.querySelectorAll("*")) {
1000
+ if (element.scrollTop !== 0 || element.scrollLeft !== 0)
1001
+ offsets.push([element, element.scrollTop, element.scrollLeft])
1002
+ }
1003
+ return offsets
1004
+ }
1005
+
1006
+ /** What a page showed as it was hidden, put back by {@link PageView.restore}. */
1007
+ export interface PageView {
1008
+ /** Put back the inner scroll offsets and the focus, once the page is in the document again. */
1009
+ restore(): void
1010
+ }
1011
+
1012
+ /**
1013
+ * Note the inner scroll offsets of a page's top-level `elements` and the element inside them that has
1014
+ * focus, while they are still laid out. For an adapter whose framework takes a hidden page out of the
1015
+ * document itself (Vue's `KeepAlive`), in place of {@link createSlotParking}.
1016
+ */
1017
+ export function rememberView(elements: readonly Element[]): PageView {
1018
+ const offsets = elements.flatMap(scrollOffsets)
1019
+ const focused = typeof document === "undefined" ? null : document.activeElement
1020
+ const focus =
1021
+ focused !== null && elements.some((element) => element.contains(focused)) ? focused : undefined
1022
+ return {
1023
+ restore() {
1024
+ for (const [element, top, left] of offsets) {
1025
+ if (!element.isConnected) continue
1026
+ element.scrollTop = top
1027
+ element.scrollLeft = left
1028
+ }
1029
+ if (focus?.isConnected)
1030
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: it held focus, so it is focusable; the call stays optional
1031
+ (focus as HTMLElement).focus?.({ preventScroll: true })
1032
+ },
1033
+ }
1034
+ }
1035
+
1036
+ const wrappedNodes = (nodes: readonly SlotNode[], into: SlotNode[] = []): SlotNode[] => {
1037
+ for (const node of nodes) {
1038
+ if (node.wrapped) into.push(node)
1039
+ if (node.children !== undefined) wrappedNodes(node.children, into)
1040
+ }
1041
+ return into
1042
+ }
1043
+
1044
+ // Development only: what each slot renders, by slot key, for the devtools panel's node counts.
1045
+ const keptViews = new Map<string, () => readonly Element[]>()
1046
+
1047
+ /**
1048
+ * For an adapter, in development: the elements slot `key` renders, read when the devtools panel
1049
+ * counts a kept page's nodes. `undefined` once the slot has left the view.
1050
+ */
1051
+ export function noteKeptView(key: string, elements: (() => readonly Element[]) | undefined): void {
1052
+ if (elements === undefined) keptViews.delete(key)
1053
+ else keptViews.set(key, elements)
1054
+ }
1055
+
1056
+ /** Where a development build lists its kept pages, for `@nifrajs/devtools`. */
1057
+ export const KEPT_PAGES_GLOBAL = "__nifraKeepAlive"
1058
+
1059
+ /** One kept page, as the devtools panel lists it. */
1060
+ export interface KeptPageInfo {
1061
+ readonly instance: string
1062
+ readonly path: string
1063
+ readonly routeId: string
1064
+ /** How long it has been hidden, in milliseconds. */
1065
+ readonly hiddenFor: number
1066
+ /** The elements it renders, while its view has them. */
1067
+ readonly nodes: number | undefined
1068
+ }
1069
+
1070
+ const describeKept = (records: Iterable<Kept>): KeptPageInfo[] => {
1071
+ const at = now()
1072
+ return [...records].map(({ page, hiddenAt }) => {
1073
+ const elements = keptViews.get(`\0${page.instance}`)?.()
1074
+ return {
1075
+ instance: page.instance,
1076
+ path: page.state.path,
1077
+ routeId: page.state.routeId,
1078
+ hiddenFor: at - hiddenAt,
1079
+ nodes: elements?.reduce(
1080
+ (count, element) => count + 1 + element.querySelectorAll("*").length,
1081
+ 0,
1082
+ ),
1083
+ }
1084
+ })
1085
+ }
1086
+
1087
+ /** One per mounted view. */
1088
+ export function createSlotParking(): SlotParking {
1089
+ const slots = new Map<string, Parked>()
1090
+ const entryRefs = new Map<string, (element: Element | null) => void>()
1091
+ const pageRefs = new Map<string, (element: Element | null) => void>()
1092
+ const slotOf = (key: string): Parked => {
1093
+ let slot = slots.get(key)
1094
+ if (slot === undefined) {
1095
+ slot = {
1096
+ parked: false,
1097
+ shown: false,
1098
+ showing: false,
1099
+ hid: false,
1100
+ next: null,
1101
+ offsets: undefined,
1102
+ focus: undefined,
1103
+ checked: false,
1104
+ }
1105
+ slots.set(key, slot)
1106
+ }
1107
+ return slot
1108
+ }
1109
+ const remember = (slot: Parked): void => {
1110
+ if (slot.page === undefined || slot.offsets !== undefined) return
1111
+ slot.offsets = scrollOffsets(slot.page)
1112
+ const focused = typeof document === "undefined" ? null : document.activeElement
1113
+ slot.focus =
1114
+ focused !== null && focused !== slot.page && slot.page.contains(focused) ? focused : undefined
1115
+ }
1116
+ // Sent while the page is still in the document, before the router's subscribers see the state that
1117
+ // hides it, so what holds the page's content (server components) sets it aside first.
1118
+ const fire = (slot: Parked, hide: boolean): void => {
1119
+ slot.hid = hide
1120
+ slot.page?.dispatchEvent(
1121
+ new CustomEvent(hide ? PAGE_HIDE_EVENT : PAGE_SHOW_EVENT, { bubbles: true }),
1122
+ )
1123
+ }
1124
+ const unpark = (slot: Parked): void => {
1125
+ if (!slot.parked || slot.page === undefined || slot.entry === undefined) return
1126
+ slot.entry.insertBefore(slot.page, slot.next?.parentNode === slot.entry ? slot.next : null)
1127
+ slot.parked = false
1128
+ slot.showing = true
1129
+ }
1130
+ return {
1131
+ entry(key) {
1132
+ let ref = entryRefs.get(key)
1133
+ if (ref === undefined) {
1134
+ ref = (element) => {
1135
+ slotOf(key).entry = element ?? undefined
1136
+ }
1137
+ entryRefs.set(key, ref)
1138
+ }
1139
+ return ref
1140
+ },
1141
+ page(key) {
1142
+ let ref = pageRefs.get(key)
1143
+ if (ref === undefined) {
1144
+ // A null never clears the page: React detaches refs inside a hidden `Activity`, and a parked
1145
+ // page is still the framework's element. The slot leaving the tree (`after`) drops it.
1146
+ ref = (element) => {
1147
+ if (element === null) return
1148
+ slotOf(key).page = element
1149
+ if (process.env.NODE_ENV !== "production") noteKeptView(key, () => [element])
1150
+ }
1151
+ pageRefs.set(key, ref)
1152
+ }
1153
+ return ref
1154
+ },
1155
+ before(next) {
1156
+ for (const node of wrappedNodes(next)) {
1157
+ const slot = slots.get(node.key)
1158
+ if (slot === undefined) continue
1159
+ if (node.hidden) {
1160
+ if (!slot.shown) continue
1161
+ remember(slot)
1162
+ if (!slot.hid && slot.page !== undefined) fire(slot, true)
1163
+ } else {
1164
+ // Shown again before it was parked: it never left the document.
1165
+ if (slot.hid && !slot.parked) fire(slot, false)
1166
+ unpark(slot)
1167
+ }
1168
+ }
1169
+ },
1170
+ after(tree) {
1171
+ const live = new Set<string>()
1172
+ for (const node of wrappedNodes(tree)) {
1173
+ live.add(node.key)
1174
+ const slot = slots.get(node.key)
1175
+ const page = slot?.page
1176
+ if (slot === undefined || page === undefined || slot.entry === undefined) continue
1177
+ if (node.hidden) {
1178
+ if (!slot.parked) {
1179
+ if (slot.shown) remember(slot)
1180
+ if (!slot.hid) fire(slot, true)
1181
+ slot.next = page.nextSibling
1182
+ page.remove()
1183
+ slot.parked = true
1184
+ }
1185
+ slot.shown = false
1186
+ continue
1187
+ }
1188
+ unpark(slot)
1189
+ if (slot.showing) {
1190
+ slot.showing = false
1191
+ for (const [element, top, left] of slot.offsets ?? []) {
1192
+ if (!page.contains(element)) continue
1193
+ element.scrollTop = top
1194
+ element.scrollLeft = left
1195
+ }
1196
+ const focus = slot.focus
1197
+ if (focus?.isConnected && page.contains(focus))
1198
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: it held focus, so it is focusable; the call stays optional
1199
+ (focus as HTMLElement).focus?.({ preventScroll: true })
1200
+ fire(slot, false)
1201
+ }
1202
+ slot.offsets = undefined
1203
+ slot.focus = undefined
1204
+ if (!slot.shown && !slot.checked) {
1205
+ slot.checked = true
1206
+ if (process.env.NODE_ENV !== "production") checkSlotCss(slot.entry, page)
1207
+ }
1208
+ slot.shown = true
1209
+ }
1210
+ for (const key of [...slots.keys()]) {
1211
+ if (live.has(key)) continue
1212
+ const page = slots.get(key)?.page
1213
+ if (page !== undefined) releasePage(page)
1214
+ slots.delete(key)
1215
+ entryRefs.delete(key)
1216
+ pageRefs.delete(key)
1217
+ if (process.env.NODE_ENV !== "production") noteKeptView(key, undefined)
1218
+ }
1219
+ },
1220
+ }
1221
+ }
1222
+
1223
+ // The selectors already reported, once per document.
1224
+ const reportedCss = new Set<string>()
1225
+ // Pseudo-classes that read an element's position among its siblings, which the wrappers change.
1226
+ const POSITIONAL =
1227
+ /:(?:first-child|last-child|only-child|nth-child\([^)]*\)|nth-last-child\([^)]*\))/g
1228
+ const DIRECT_HAS = /:has\(\s*>\s*([^)]*)\)/
1229
+
1230
+ /** `selector`'s compounds and the combinators between them, split outside parentheses and brackets. */
1231
+ const compoundsOf = (
1232
+ selector: string,
1233
+ ): { readonly compound: string; readonly before: string }[] => {
1234
+ const parts: { compound: string; before: string }[] = []
1235
+ let depth = 0
1236
+ let start = 0
1237
+ let combinator = ""
1238
+ const flush = (end: number): void => {
1239
+ const compound = selector.slice(start, end).trim()
1240
+ if (compound !== "") {
1241
+ parts.push({ compound, before: combinator })
1242
+ combinator = ""
1243
+ }
1244
+ }
1245
+ for (let i = 0; i < selector.length; i++) {
1246
+ const char = selector[i]
1247
+ if (char === "(" || char === "[") depth++
1248
+ else if (char === ")" || char === "]") depth--
1249
+ if (depth > 0 || (char !== " " && char !== ">" && char !== "+" && char !== "~")) continue
1250
+ flush(i)
1251
+ if (char !== " ") combinator = char
1252
+ else if (combinator === "") combinator = " "
1253
+ start = i + 1
1254
+ }
1255
+ if (depth <= 0) flush(selector.length)
1256
+ return parts
1257
+ }
1258
+
1259
+ const matches = (element: Element, selector: string): boolean => {
1260
+ try {
1261
+ return element.matches(selector)
1262
+ } catch {
1263
+ return false
1264
+ }
1265
+ }
1266
+
1267
+ // Whether `selector` targets a top-level node of the page as a child of the element the slot sits in,
1268
+ // which the two wrappers between them now break.
1269
+ const breaksAround = (selector: string, parent: Element, tops: readonly Element[]): boolean => {
1270
+ const parts = compoundsOf(selector)
1271
+ for (const [i, { compound, before }] of parts.entries()) {
1272
+ const prefix = parts
1273
+ .slice(0, i)
1274
+ .map((part, j) => (j === 0 ? part.compound : `${part.before} ${part.compound}`))
1275
+ .join(" ")
1276
+ if (before === ">" && matches(parent, prefix) && tops.some((top) => matches(top, compound)))
1277
+ return true
1278
+ if (POSITIONAL.test(compound)) {
1279
+ POSITIONAL.lastIndex = 0
1280
+ const rest = compound.replace(POSITIONAL, "") || "*"
1281
+ if (
1282
+ tops.some((top) => matches(top, rest)) &&
1283
+ (prefix === "" || matches(parent, prefix) || parent.closest(prefix) !== null)
1284
+ )
1285
+ return true
1286
+ }
1287
+ POSITIONAL.lastIndex = 0
1288
+ const has = DIRECT_HAS.exec(compound)
1289
+ if (has !== null) {
1290
+ const owner = compound.replace(DIRECT_HAS, "") || "*"
1291
+ if (matches(parent, owner) && tops.some((top) => matches(top, has[1] ?? ""))) return true
1292
+ }
1293
+ }
1294
+ return false
1295
+ }
1296
+
1297
+ const scanRules = (
1298
+ rules: CSSRuleList,
1299
+ parent: Element,
1300
+ tops: readonly Element[],
1301
+ source: string,
1302
+ ): void => {
1303
+ for (const rule of rules) {
1304
+ if (rule instanceof CSSStyleRule) {
1305
+ for (const selector of splitSelectors(rule.selectorText)) {
1306
+ if (reportedCss.has(selector) || !breaksAround(selector, parent, tops)) continue
1307
+ reportedCss.add(selector)
1308
+ console.warn(
1309
+ `[nifra/web] NIFRA_KEEPALIVE_DIRECT_CHILD_CSS: the selector "${selector}"${source} reaches a kept page's top-level element as a direct child of <${parent.tagName.toLowerCase()}>, but a keepAlive page renders inside two display:contents wrappers, so it no longer matches. Use a descendant combinator, target the wrapper ([data-nifra-page] > ...), or put a class on the page's root element.`,
1310
+ )
1311
+ }
1312
+ } else if ("cssRules" in rule) {
1313
+ // @media, @supports, @layer and @container rules hold style rules of their own.
1314
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a rule with cssRules is a grouping rule
1315
+ scanRules((rule as CSSGroupingRule).cssRules, parent, tops, source)
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ const splitSelectors = (list: string): string[] => {
1321
+ const out: string[] = []
1322
+ let depth = 0
1323
+ let start = 0
1324
+ for (let i = 0; i < list.length; i++) {
1325
+ const char = list[i]
1326
+ if (char === "(" || char === "[") depth++
1327
+ else if (char === ")" || char === "]") depth--
1328
+ else if (char === "," && depth === 0) {
1329
+ out.push(list.slice(start, i).trim())
1330
+ start = i + 1
1331
+ }
1332
+ }
1333
+ out.push(list.slice(start).trim())
1334
+ return out.filter((selector) => selector !== "")
1335
+ }
1336
+
1337
+ /**
1338
+ * Dev only: report each same-origin style rule whose selector the slot wrappers around `page` stop
1339
+ * matching (`NIFRA_KEEPALIVE_DIRECT_CHILD_CSS`). A stylesheet the page cannot read is skipped.
1340
+ */
1341
+ export function checkSlotCss(entry: Element, page: Element): void {
1342
+ const parent = entry.parentElement
1343
+ const tops = [...page.children]
1344
+ if (parent === null || tops.length === 0 || typeof document === "undefined") return
1345
+ for (const sheet of document.styleSheets) {
1346
+ let rules: CSSRuleList
1347
+ try {
1348
+ rules = sheet.cssRules
1349
+ } catch {
1350
+ continue
1351
+ }
1352
+ scanRules(rules, parent, tops, sheet.href === null ? "" : ` (${sheet.href})`)
1353
+ }
1354
+ }