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