@nifrajs/web 3.5.0 → 4.0.2

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 (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
package/src/router.ts CHANGED
@@ -10,6 +10,7 @@ import type { StandardSchemaV1 } from "@nifrajs/core/server"
10
10
  import type { BoundaryDescriptor, BoundaryStates } from "./boundary.ts"
11
11
  import { parseNdjsonData } from "./deferred.ts"
12
12
  import type { ClientActionResult, ClientRequestBody, ClientRouteHooks } from "./manifest.ts"
13
+ import type { MatchChain } from "./render-seam.ts"
13
14
  import { isClientOnlySearchChange } from "./search.ts"
14
15
 
15
16
  /**
@@ -25,6 +26,10 @@ export const DATA_HEADER = "x-nifra-data"
25
26
  */
26
27
  export const PRERENDERED_GLOBAL = "__NIFRA_PRERENDERED__"
27
28
 
29
+ /** Where `createWebApp` serves a prerendered set too large to inline; the global then holds this URL
30
+ * plus a `?v=` content version, so the browser may cache it for good. */
31
+ export const PRERENDERED_LIST_PATH = "/__nifra/prerendered.json"
32
+
28
33
  /**
29
34
  * Response header a data-mode action POST uses to convey a redirect (`redirect(...)`) to the
30
35
  * client - fetch would otherwise silently follow a 3xx to its HTML, losing the target. The
@@ -138,8 +143,9 @@ export interface RouterState {
138
143
  readonly actionData?: unknown
139
144
  /** True while a navigation or submit is in flight (drives loading UI). */
140
145
  readonly pending: boolean
141
- /** The path a navigation is transitioning TO while `pending` (cleared when it settles). Lets a
142
- * `NavLink` know whether its own `to` is the one loading; `undefined` when idle. */
146
+ /** The path a navigation or a submit's redirect is loading while `pending`; a redirect the router
147
+ * follows moves it to the target. Lets a `NavLink` know whether its own `to` is the one loading;
148
+ * `undefined` when idle. */
143
149
  readonly pendingPath?: string | undefined
144
150
  /** The in-flight submit (set during a `submit`, cleared when it settles) - for optimistic UI. */
145
151
  readonly submission?: Submission
@@ -197,8 +203,10 @@ export type FetchRouteData = (
197
203
 
198
204
  /** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
199
205
  export interface SubmitOptions {
200
- /** Re-run the active route's loader after the action settles (default `true`). Set `false` to
201
- * keep the current `data` and rely on the action's `actionData` alone. */
206
+ /** Re-run the active route's loaders after the action settles (default `true`). Set `false` to
207
+ * keep the current `data` and rely on the action's `actionData` alone. When the action redirects,
208
+ * the target's layouts all re-run by default; with `false` it keeps the layout data an ordinary
209
+ * navigation would. */
202
210
  readonly revalidate?: boolean
203
211
  }
204
212
 
@@ -238,7 +246,11 @@ export interface ClientRouter {
238
246
  snapshot: () => RouterState
239
247
  /** Subscribe to transitions; returns an unsubscribe fn. */
240
248
  subscribe: (listener: () => void) => () => void
241
- /** Navigate to a path: match → fetch loader data → publish. No-op for an unmatched path. */
249
+ /**
250
+ * Navigate to a path: match → fetch loader data → publish. No-op for an unmatched path. A loader
251
+ * redirect to another route is followed; one that leaves the app (another origin, a `#fragment`, no
252
+ * route) rejects with its target as `redirectTo`.
253
+ */
242
254
  navigate: (path: string) => Promise<void>
243
255
  /**
244
256
  * Submit an action (POST `body` to `action` in data mode): a redirect becomes a client
@@ -246,7 +258,8 @@ export interface ClientRouter {
246
258
  * loader is revalidated so the mutation is reflected. Pass `{ revalidate: false }` to SKIP that
247
259
  * revalidation - keep the current `data` and just publish the action's `actionData` (useful when
248
260
  * the action already returned everything that changed, saving the extra round-trip). A redirect
249
- * always loads its target regardless. Rejects on failure (caller falls back).
261
+ * always loads its target regardless. Rejects on failure (caller falls back); once the action has
262
+ * run, the rejection's `redirectTo` names the page to load instead of posting again.
250
263
  */
251
264
  submit: (
252
265
  action: string,
@@ -319,6 +332,10 @@ export interface MountRouterOptions {
319
332
  * `ctx.search`/`RenderProps.search`. Omitted by callers with no typed search (tests, a hand-built mount)
320
333
  * ⇒ every route sees the raw parsed query. */
321
334
  readonly searchSchemas?: Readonly<Record<string, readonly (StandardSchemaV1 | undefined)[]>>
335
+ /** routeId → the ids and `handle` exports `useMatches` reports for that chain; built by
336
+ * `generateClientEntry` as each route's modules load. The mount hands the current route's entry to
337
+ * the render as `RenderProps.matchChain`. Omitted ⇒ `useMatches` returns an empty list. */
338
+ readonly matchChains?: Readonly<Record<string, MatchChain>>
322
339
  /** Hydration container (opaque - the adapter casts it to its DOM element type). */
323
340
  readonly container: unknown
324
341
  }
@@ -356,11 +373,45 @@ const prerenderedSetOf = (paths: object): ReadonlySet<unknown> => {
356
373
  return set
357
374
  }
358
375
 
376
+ // A handed-over URL is fetched once: the global then holds the pending list every navigation shares.
377
+ // A failed fetch leaves navigations on the dynamic path, which is always correct.
378
+ const prerenderedOf = (): unknown => {
379
+ const handed = Reflect.get(globalThis, PRERENDERED_GLOBAL)
380
+ if (typeof handed !== "string") return handed
381
+ const list = fetch(handed)
382
+ .then((res) => (res.ok ? res.json() : undefined))
383
+ .catch(() => undefined)
384
+ Reflect.set(globalThis, PRERENDERED_GLOBAL, list)
385
+ return list
386
+ }
387
+
388
+ /**
389
+ * The page to load as a document, when that is why a navigation or submit rejected. Only an `http:` or
390
+ * `https:` target: the caller hands it to `location`, which runs a `javascript:` URL in this page,
391
+ * where the same target in a real redirect's `Location` never runs.
392
+ */
393
+ export const redirectOf = (error: unknown): string | undefined => {
394
+ const to = (error as { redirectTo?: unknown } | null | undefined)?.redirectTo
395
+ return typeof to === "string" && /^https?:$/.test(targetUrl(to)?.protocol ?? "") ? to : undefined
396
+ }
397
+
398
+ /** A redirect target resolved against a placeholder origin, or `undefined` when it does not parse. */
399
+ const targetUrl = (to: string): URL | undefined => {
400
+ try {
401
+ return new URL(to, "http://n.invalid")
402
+ } catch {
403
+ return undefined
404
+ }
405
+ }
406
+
407
+ const redirected = (to: string): Error =>
408
+ Object.assign(new Error(`[nifra/web] redirected to ${to}`), { redirectTo: to })
409
+
359
410
  const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
360
411
  // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
361
412
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
362
413
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
363
- const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
414
+ const prerendered = await prerenderedOf()
364
415
  if (Array.isArray(prerendered)) {
365
416
  const query = path.indexOf("?")
366
417
  const hash = path.indexOf("#")
@@ -379,6 +430,8 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
379
430
  headers.set(NAV_FROM_HEADER, navigation.from)
380
431
  }
381
432
  const res = await fetch(path, { headers, signal: signal ?? null })
433
+ const redirectTo = res.headers.get(REDIRECT_HEADER)
434
+ if (redirectTo !== null) throw redirected(redirectTo)
382
435
  const status = Number(res.headers.get(STATUS_HEADER))
383
436
  if (Number.isInteger(status) && status >= 400 && status <= 599) {
384
437
  return { v: 1, data: null, status } satisfies RouteDataEnvelope
@@ -411,6 +464,8 @@ const isClientActionResult = (value: unknown): value is ClientActionResult =>
411
464
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
412
465
  const match = createMatcher(options.patterns)
413
466
  const fetchData = options.fetchData ?? defaultFetchData
467
+ // Start a handed-over list's fetch now, so the first navigation rarely waits for it.
468
+ if (fetchData === defaultFetchData) prerenderedOf()
414
469
  // routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
415
470
  // to it as routes load), so a same-route nav always sees the current route's keys.
416
471
  const searchClientKeys = options.searchClientKeys ?? {}
@@ -426,7 +481,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
426
481
  sourceRouteId: string,
427
482
  path: string,
428
483
  target: RouteMatch,
429
- ): BoundaryDescriptor | undefined => {
484
+ ): Omit<BoundaryDescriptor, "hasLoad"> | undefined => {
430
485
  const boundaries = routeHooks[sourceRouteId]?.boundaries ?? []
431
486
  const pathname = pathnameOf(path)
432
487
  for (const boundary of boundaries) {
@@ -473,8 +528,12 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
473
528
  path: string,
474
529
  match: { routeId: string; params: Record<string, string> },
475
530
  signal?: AbortSignal,
476
- retainFrom?: { readonly path: string; readonly layoutData: readonly unknown[] },
531
+ // Offer the server the layout data on screen: it re-runs whichever of those loaders it must.
532
+ keepLayouts?: boolean,
477
533
  ): Promise<LoadedRouteData> => {
534
+ const retainFrom = keepLayouts
535
+ ? state.layoutData && { path: state.path, layoutData: state.layoutData }
536
+ : undefined
478
537
  const retain = retainFrom?.layoutData.map((_, index) => index) ?? []
479
538
  const payload = await fetchData(
480
539
  path,
@@ -621,11 +680,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
621
680
  if (listeners.size === 0) return
622
681
  for (const listener of [...listeners]) listener()
623
682
  }
624
- // Bounded one-shot prefetch cache (path → loader data) + an in-flight guard so hover spam
625
- // doesn't double-fetch. Consumed (and dropped) by the next navigate to that path.
683
+ // Bounded one-shot prefetch cache (path → loader data, and when it arrived) + an in-flight guard so
684
+ // hover spam doesn't double-fetch. Consumed (and dropped) by the next navigate to that path - while
685
+ // fresh: a link warmed as it scrolled into view can be clicked minutes later.
626
686
  const MAX_PREFETCH = 10
627
- const prefetched = new Map<string, LoadedRouteData>()
687
+ const PREFETCH_MAX_AGE_MS = 30_000
688
+ const prefetched = new Map<string, readonly [LoadedRouteData, number]>()
628
689
  const inflight = new Set<string>()
690
+ const freshPrefetch = (path: string): LoadedRouteData | undefined => {
691
+ const held = prefetched.get(path)
692
+ return held !== undefined && Date.now() - held[1] < PREFETCH_MAX_AGE_MS ? held[0] : undefined
693
+ }
629
694
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
630
695
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
631
696
  // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
@@ -684,6 +749,56 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
684
749
  // (so a superseded route's NDJSON stream stops reading instead of draining in the background).
685
750
  let navAbort: AbortController | undefined
686
751
 
752
+ // The route path an in-app redirect target leads to. Undefined when the browser has to load it: another
753
+ // origin, a `#fragment` to scroll to, or no route.
754
+ const appRedirect = (to: string | undefined): readonly [string, RouteMatch] | undefined => {
755
+ if (to === undefined || !to.startsWith("/") || to.includes("#")) return undefined
756
+ const url = targetUrl(to)
757
+ if (url?.host !== "n.invalid") return undefined // `//host`, `/\host`, unparseable
758
+ const path = url.pathname + url.search
759
+ const target = match(path)
760
+ return target === null ? undefined : [path, target]
761
+ }
762
+
763
+ // Load `path` for the navigation or submit `mine`, following the redirects its loaders answer with
764
+ // while they stay in the app (20 at most, as a browser allows).
765
+ const loadFollowing = async (
766
+ mine: number,
767
+ path: string,
768
+ target: RouteMatch,
769
+ signal: AbortSignal,
770
+ keepLayouts: boolean,
771
+ usePrefetch: boolean,
772
+ ): Promise<readonly [string, RouteMatch, LoadedRouteData]> => {
773
+ let current: readonly [string, RouteMatch] = [path, target]
774
+ for (let hops = 0; ; hops++) {
775
+ const [at, route] = current
776
+ if (mine === generation && state.pendingPath !== at) {
777
+ state = { ...state, pendingPath: at }
778
+ emit()
779
+ }
780
+ await loadModule?.(route.routeId)
781
+ // Prefetched server data is one-shot; a clientLoader still runs over it without another request.
782
+ const prefetchedData = usePrefetch ? freshPrefetch(at) : undefined
783
+ if (usePrefetch) prefetched.delete(at)
784
+ try {
785
+ const loaded = await applyClientLoader(
786
+ at,
787
+ route,
788
+ signal,
789
+ () => loadRouteData(at, route, signal, keepLayouts),
790
+ prefetchedData,
791
+ )
792
+ if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
793
+ return [at, route, loaded]
794
+ } catch (error) {
795
+ const next = hops < 20 ? appRedirect(redirectOf(error)) : undefined
796
+ if (next === undefined) throw error
797
+ current = next
798
+ }
799
+ }
800
+ }
801
+
687
802
  // Refetch the active route + republish, single-flight via the shared `generation`. Shared by `invalidate`
688
803
  // and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
689
804
  const refetchActive = async (): Promise<void> => {
@@ -890,35 +1005,12 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
890
1005
  state = { ...state, pending: true, pendingPath: path }
891
1006
  emit()
892
1007
  try {
893
- // Load the route chunk before invoking a client loader. A route without a client loader still
894
- // uses the same server-data path; a route with one gets a lazy `serverLoader()` thunk.
895
- await loadModule?.(matched.routeId)
896
- // Use prefetched server data when present (one-shot - drop it); clientLoader still runs over
897
- // that cached result without causing another network request.
898
- const hit = prefetched.has(path)
899
- const prefetchedData = hit ? (prefetched.get(path) as LoadedRouteData) : undefined
900
- if (hit) prefetched.delete(path)
901
- const loaded = await applyClientLoader(
902
- path,
903
- matched,
904
- ac.signal,
905
- () =>
906
- loadRouteData(
907
- path,
908
- matched,
909
- ac.signal,
910
- state.layoutData === undefined
911
- ? undefined
912
- : { path: state.path, layoutData: state.layoutData },
913
- ),
914
- prefetchedData,
915
- )
916
- if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
1008
+ const [to, target, loaded] = await loadFollowing(mine, path, matched, ac.signal, true, true)
917
1009
  if (mine !== generation) return // a newer navigation superseded this one - drop the stale result
918
- cachePut(path, loaded) // keep the keyed cache coherent with what we publish
1010
+ cachePut(to, loaded) // keep the keyed cache coherent with what we publish
919
1011
  const interception =
920
1012
  loaded.terminalRouteId === undefined
921
- ? interceptionFor(state.routeId, path, matched)
1013
+ ? interceptionFor(state.routeId, to, target)
922
1014
  : undefined
923
1015
  if (interception !== undefined) {
924
1016
  // The target's ordinary data request has already completed (including its server authz and
@@ -927,7 +1019,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
927
1019
  // this branch, so an unauthorized/missing target cannot become a modal.
928
1020
  state = {
929
1021
  ...state,
930
- path,
1022
+ path: to,
931
1023
  boundaries: {
932
1024
  ...(state.boundaries ?? {}),
933
1025
  [interception.name]: {
@@ -946,9 +1038,9 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
946
1038
  return
947
1039
  }
948
1040
  state = {
949
- routeId: loaded.terminalRouteId ?? matched.routeId,
950
- params: loaded.terminalRouteId === undefined ? matched.params : {},
951
- path,
1041
+ routeId: loaded.terminalRouteId ?? target.routeId,
1042
+ params: loaded.terminalRouteId === undefined ? target.params : {},
1043
+ path: to,
952
1044
  data: loaded.data,
953
1045
  layoutData: loaded.layoutData,
954
1046
  ...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
@@ -983,6 +1075,9 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
983
1075
  ...(body instanceof FormData ? { submission: { action, formData: body } } : {}),
984
1076
  }
985
1077
  emit()
1078
+ // Once the action has run, falling back to posting the form again would run it twice.
1079
+ let posted = false
1080
+ let landing: string | undefined
986
1081
  try {
987
1082
  const prepared = await prepareClientAction(action, body, ac.signal)
988
1083
  if (Object.hasOwn(prepared, "optimisticData")) {
@@ -995,30 +1090,30 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
995
1090
  headers: { [DATA_HEADER]: "1" },
996
1091
  })
997
1092
  if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
1093
+ posted = true
998
1094
  const redirectTo = res.headers.get(REDIRECT_HEADER)
999
1095
  if (redirectTo !== null) {
1000
1096
  // The action redirected (Post/Redirect/Get) - treat it as a client navigation.
1001
- const target = match(redirectTo)
1002
- if (target === null)
1003
- throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
1004
- await loadModule?.(target.routeId)
1005
- const loaded = await applyClientLoader(redirectTo, target, ac.signal, () =>
1006
- loadRouteData(
1007
- redirectTo,
1008
- target,
1009
- ac.signal,
1010
- state.layoutData === undefined
1011
- ? undefined
1012
- : { path: state.path, layoutData: state.layoutData },
1013
- ),
1097
+ const next = appRedirect(redirectTo)
1098
+ if (next === undefined) throw redirected(redirectTo)
1099
+ landing = next[0]
1100
+ // The action may have changed what any layout shows, so the target loads its whole chain,
1101
+ // the same as the non-redirect revalidation below. `revalidate: false` keeps layout data
1102
+ // the server agrees is unchanged, like an ordinary navigation.
1103
+ const [to, target, loaded] = await loadFollowing(
1104
+ mine,
1105
+ next[0],
1106
+ next[1],
1107
+ ac.signal,
1108
+ opts?.revalidate === false,
1109
+ false,
1014
1110
  )
1015
- if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
1016
1111
  if (mine !== generation) return
1017
- cachePut(redirectTo, loaded)
1112
+ cachePut(to, loaded)
1018
1113
  state = {
1019
1114
  routeId: loaded.terminalRouteId ?? target.routeId,
1020
1115
  params: loaded.terminalRouteId === undefined ? target.params : {},
1021
- path: redirectTo,
1116
+ path: to,
1022
1117
  data: loaded.data,
1023
1118
  layoutData: loaded.layoutData,
1024
1119
  ...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
@@ -1071,19 +1166,23 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1071
1166
  await refreshMounted(changed, true)
1072
1167
  } catch (err) {
1073
1168
  if (mine === generation) {
1074
- // Revert: clear `submission` (the optimistic view vanishes) leaving `data` untouched, so the
1075
- // pre-submit data shows through.
1169
+ // Revert: clear `submission` (the optimistic view vanishes) leaving the data untouched, so the
1170
+ // pre-submit page shows through.
1076
1171
  state = {
1077
1172
  routeId: state.routeId,
1078
1173
  params: state.params,
1079
1174
  path: state.path,
1080
1175
  data: state.data,
1176
+ layoutData: state.layoutData,
1177
+ ...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
1081
1178
  actionData: previousActionData,
1082
1179
  pending: false,
1083
1180
  }
1084
1181
  emit()
1085
1182
  }
1086
- throw err
1183
+ if (!posted) throw err
1184
+ if (mine !== generation) return // a newer navigation or submit owns the page
1185
+ throw redirectOf(err) === undefined ? redirected(landing ?? state.path) : err
1087
1186
  }
1088
1187
  },
1089
1188
  hydrate,
@@ -1101,7 +1200,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1101
1200
  await Promise.all(jobs)
1102
1201
  },
1103
1202
  prefetch: async (path) => {
1104
- if (prefetched.has(path) || inflight.has(path)) return
1203
+ if (inflight.has(path) || freshPrefetch(path) !== undefined) return
1105
1204
  const matched = match(path)
1106
1205
  if (matched === null) return
1107
1206
  inflight.add(path)
@@ -1112,11 +1211,12 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1112
1211
  loadModule?.(matched.routeId),
1113
1212
  loadRouteData(path, matched),
1114
1213
  ])
1214
+ prefetched.delete(path) // a stale entry re-enters as the newest
1115
1215
  if (prefetched.size >= MAX_PREFETCH) {
1116
1216
  const oldest = prefetched.keys().next().value
1117
1217
  if (oldest !== undefined) prefetched.delete(oldest)
1118
1218
  }
1119
- prefetched.set(path, loaded)
1219
+ prefetched.set(path, [loaded, Date.now()])
1120
1220
  } catch {
1121
1221
  // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
1122
1222
  } finally {
package/src/vitals.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Core Web Vitals from Google's `web-vitals` (an optional peer), each tagged with its route. */
2
+ import type { MetricType } from "web-vitals"
3
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"
4
+ import { reportWith } from "./internal/vitals-runtime.ts"
5
+
6
+ /**
7
+ * One measurement from `web-vitals` - `name` is `"LCP"`, `"INP"`, `"CLS"`, `"FCP"` or `"TTFB"`, with its
8
+ * `value`, `rating` (`"good"`, `"needs-improvement"` or `"poor"`), `delta` since the last report and an
9
+ * `id` unique to the measurement - plus the route it belongs to.
10
+ */
11
+ export type WebVitalsMetric = MetricType & {
12
+ /** The id of the route the measured page matched (`users/[id]`, the id `useMatches` reports).
13
+ * `undefined` before the router mounts, for a page no route matches, or for another origin. */
14
+ readonly route: string | undefined
15
+ }
16
+
17
+ /** How {@link reportWebVitals} reports. */
18
+ export interface ReportWebVitalsOptions {
19
+ /** Report every change to a metric (LCP, CLS and INP grow while the page is open), not only the
20
+ * value it settles on. Use `delta` or `id` to aggregate. Default `false`. */
21
+ readonly reportAllChanges?: boolean
22
+ /** Measure each client-side navigation as a page view of its own where the browser can (Chromium
23
+ * 151 and later), so its metrics carry the route it navigated to. The first page's metrics then
24
+ * settle at the first client navigation; other browsers report as if this were off. Default `false`. */
25
+ readonly softNavigations?: boolean
26
+ }
27
+
28
+ const library = { onCLS, onFCP, onINP, onLCP, onTTFB }
29
+
30
+ /**
31
+ * Report the page's Core Web Vitals to `report`, each with the route it belongs to. A metric is
32
+ * reported once its value is final - FCP and TTFB as soon as they are known, LCP at the first
33
+ * interaction or when the page is hidden, CLS and INP when the page is hidden - unless
34
+ * `reportAllChanges` asks for every change. The browser buffers the load metrics recorded before the
35
+ * call, so a mount effect after hydration is early enough.
36
+ *
37
+ * Returns a function that stops reporting. On the server it does nothing.
38
+ */
39
+ export function reportWebVitals(
40
+ report: (metric: WebVitalsMetric) => void,
41
+ options: ReportWebVitalsOptions = {},
42
+ ): () => void {
43
+ return reportWith(library, report, options)
44
+ }