@nifrajs/web 3.3.0 → 4.0.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 (407) 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 +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  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 +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  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/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `@nifrajs/web/route-types` - the generated `./+types/<route>` modules.
3
+ *
4
+ * Every route file gets `.nifra/types/<routes>/<dir>/+types/<name>.d.ts`, declaring a `Route`
5
+ * namespace: the route's `Params`, the `LoaderData` and `ActionData` its output schemas let through,
6
+ * the `LoaderArgs` its backend half receives, and its component's props. With
7
+ * `"rootDirs": [".", "./.nifra/types"]` in tsconfig, both halves of a route import it as
8
+ * `./+types/<name>`:
9
+ *
10
+ * // routes/blog/[slug].tsx
11
+ * import type { Route } from "./+types/[slug]"
12
+ * export default function Post({ data }: Route.ComponentProps) { ... }
13
+ *
14
+ * // routes/blog/[slug].backend.ts
15
+ * export async function loader({ api, params }: Route.LoaderArgs) { ... }
16
+ *
17
+ * `LoaderData` is the output type of `loaderOutput`, not the loader's return type: the data guard
18
+ * projects the loader's value through that schema, so the schema is what the component receives.
19
+ * `.nifra/types/register.d.ts` registers `backend/app.ts`'s `backend` with `@nifrajs/client`, so `api`
20
+ * is typed without a route importing the backend.
21
+ */
22
+ import {
23
+ type Dirent,
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from "node:fs"
31
+ import { dirname, join, relative } from "node:path"
32
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
33
+ import {
34
+ buildManifest,
35
+ isMiddlewareFile,
36
+ isRouteBackendFile,
37
+ type LayoutEntry,
38
+ type Manifest,
39
+ } from "./manifest.ts"
40
+
41
+ /** Where the generated types live, relative to the app root. */
42
+ export const ROUTE_TYPES_DIR = ".nifra/types"
43
+
44
+ const BACKEND_APP = "backend/app"
45
+ const SCRIPT_EXTS = [".ts", ".tsx", ".js", ".jsx"] as const
46
+
47
+ export interface RouteTypesOptions {
48
+ /** The app root: where `.nifra/` and `backend/app.ts` live. */
49
+ readonly appRoot: string
50
+ /** The routes directory. Defaults to `<appRoot>/routes`. */
51
+ readonly routesDir?: string
52
+ }
53
+
54
+ type Kind = "page" | "layout"
55
+
56
+ interface RouteFile {
57
+ readonly file: string
58
+ readonly backend: string | undefined
59
+ readonly kind: Kind
60
+ }
61
+
62
+ const posix = (path: string): string => path.replaceAll("\\", "/")
63
+
64
+ /** Every route file the router serves, once each: pages, layouts and the special pages. */
65
+ function routeFiles(manifest: Manifest): readonly RouteFile[] {
66
+ const files = new Map<string, RouteFile>()
67
+ const add = (entry: Pick<LayoutEntry, "file" | "backend">, kind: Kind): void => {
68
+ if (!files.has(entry.file))
69
+ files.set(entry.file, { file: entry.file, backend: entry.backend, kind })
70
+ }
71
+ for (const route of manifest.routes) add(route, "page")
72
+ for (const layout of Object.values(manifest.layouts)) add(layout, "layout")
73
+ for (const group of [
74
+ manifest.errors,
75
+ manifest.notFounds,
76
+ manifest.statusPages,
77
+ manifest.loadings,
78
+ ]) {
79
+ for (const entry of Object.values(group ?? {})) add(entry, "page")
80
+ }
81
+ if (manifest.notFound !== undefined) add(manifest.notFound, "page")
82
+ return [...files.values()].sort((a, b) => codeUnitOrder(a.file, b.file))
83
+ }
84
+
85
+ const PARAM = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/
86
+ const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
87
+ const CATCH_ALL = /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/
88
+
89
+ /** The params a route file's URL carries: its directories', plus its own name's for a page. */
90
+ function paramsType(file: string, kind: Kind): string {
91
+ const segments = file.replace(/\.[^./]+$/, "").split("/")
92
+ const own =
93
+ kind === "page" && !segments.at(-1)?.startsWith("_") ? segments : segments.slice(0, -1)
94
+ const fields: string[] = []
95
+ for (const segment of own) {
96
+ const optional = OPTIONAL.exec(segment)
97
+ const name = optional?.[1] ?? PARAM.exec(segment)?.[1] ?? CATCH_ALL.exec(segment)?.[1]
98
+ if (name !== undefined) fields.push(`readonly ${name}${optional === null ? "" : "?"}: string`)
99
+ }
100
+ return fields.length === 0 ? "Record<string, never>" : `{ ${fields.join("; ")} }`
101
+ }
102
+
103
+ /** A module specifier for `target` from a file in `fromDir`, without a script extension. */
104
+ function specifier(fromDir: string, target: string): string {
105
+ let path = posix(relative(fromDir, target))
106
+ for (const ext of SCRIPT_EXTS) if (path.endsWith(ext)) path = path.slice(0, -ext.length)
107
+ return path.startsWith(".") ? path : `./${path}`
108
+ }
109
+
110
+ const backendAppOf = (appRoot: string): string | undefined =>
111
+ SCRIPT_EXTS.map((ext) => join(appRoot, `${BACKEND_APP}${ext}`)).find((path) => existsSync(path))
112
+
113
+ function routeTypesModule(
114
+ route: RouteFile,
115
+ out: string,
116
+ routesDir: string,
117
+ label: string,
118
+ register: string | undefined,
119
+ ): string {
120
+ const dir = dirname(out)
121
+ const backend = route.backend === undefined ? undefined : join(routesDir, route.backend)
122
+ // Only a script module's exports are visible to TypeScript; a .svelte/.vue/.mdx one is an ambient shim.
123
+ const frontend = /\.[jt]sx?$/.test(route.file) ? join(routesDir, route.file) : undefined
124
+ const lines = [`// Generated by nifra from ${label}. Do not edit: \`nifra types\` rewrites it.`]
125
+ if (register !== undefined)
126
+ lines.push(`/// <reference path="${posix(relative(dir, register))}" />`)
127
+ lines.push('import type { OutputOf, RouteLoaderArgs, SearchSchemaOf } from "@nifrajs/client"')
128
+ if (route.kind === "page") lines.push('import type { RenderProps } from "@nifrajs/web"')
129
+ lines.push("")
130
+ if (backend !== undefined)
131
+ lines.push(`type Backend = typeof import("${specifier(dir, backend)}")`)
132
+ if (frontend !== undefined)
133
+ lines.push(`type Frontend = typeof import("${specifier(dir, frontend)}")`)
134
+ if (backend !== undefined || frontend !== undefined) lines.push("")
135
+ const output = (name: string) => (backend === undefined ? "null" : `OutputOf<Backend, "${name}">`)
136
+ lines.push(
137
+ "export namespace Route {",
138
+ " /** The URL params this route's path carries. */",
139
+ ` export type Params = ${paramsType(route.file, route.kind)}`,
140
+ " /** What the loader sends to the browser: the output type of `loaderOutput`. */",
141
+ ` export type LoaderData = ${output("loaderOutput")}`,
142
+ " /** What the action sends back: the output type of `actionOutput`. */",
143
+ ` export type ActionData = ${output("actionOutput")}`,
144
+ " /** The context the backend half's loader receives. */",
145
+ ` export type LoaderArgs = RouteLoaderArgs<Params${frontend === undefined ? "" : ", SearchSchemaOf<Frontend>"}>`,
146
+ " /** The context the backend half's action receives. */",
147
+ " export type ActionArgs = LoaderArgs",
148
+ )
149
+ if (route.kind === "page") {
150
+ lines.push(
151
+ " /** The page component's props. */",
152
+ ' export type ComponentProps = Omit<RenderProps, "data" | "actionData" | "params"> & {',
153
+ " readonly data: LoaderData",
154
+ " readonly actionData?: ActionData",
155
+ " readonly params: Params",
156
+ " }",
157
+ )
158
+ } else {
159
+ lines.push(
160
+ " /** The layout component's props; `Children` is the adapter's children type. */",
161
+ " export type ComponentProps<Children = unknown> = {",
162
+ " readonly data: LoaderData",
163
+ " readonly children?: Children",
164
+ " }",
165
+ )
166
+ }
167
+ lines.push("}", "")
168
+ return lines.join("\n")
169
+ }
170
+
171
+ function registerModule(out: string, backendApp: string, label: string): string {
172
+ return [
173
+ `// Generated by nifra from ${label}. Do not edit: \`nifra types\` rewrites it.`,
174
+ `import type { backend } from "${specifier(dirname(out), backendApp)}"`,
175
+ "",
176
+ 'declare module "@nifrajs/client" {',
177
+ " interface Register {",
178
+ " readonly backend: typeof backend",
179
+ " }",
180
+ "}",
181
+ "",
182
+ ].join("\n")
183
+ }
184
+
185
+ /** Every generated types file for the app, by absolute path. */
186
+ export function routeTypeFiles(options: RouteTypesOptions): ReadonlyMap<string, string> {
187
+ const appRoot = options.appRoot
188
+ const routesDir = options.routesDir ?? join(appRoot, "routes")
189
+ const typesRoot = join(appRoot, ROUTE_TYPES_DIR)
190
+ const files = new Map<string, string>()
191
+ const backendApp = backendAppOf(appRoot)
192
+ const register = backendApp === undefined ? undefined : join(typesRoot, "register.d.ts")
193
+ if (backendApp !== undefined && register !== undefined) {
194
+ files.set(register, registerModule(register, backendApp, posix(relative(appRoot, backendApp))))
195
+ }
196
+ if (!existsSync(routesDir)) return files
197
+ const listed = (readdirSync(routesDir, { recursive: true }) as string[])
198
+ .map(posix)
199
+ .filter(
200
+ (file) =>
201
+ /\.(?:tsx|jsx|svelte|vue|mdx)$/.test(file) ||
202
+ isRouteBackendFile(file) ||
203
+ isMiddlewareFile(file),
204
+ )
205
+ // Discovery only: the importer is never called.
206
+ const manifest = buildManifest(listed, () => () => Promise.reject(new Error("not loaded")))
207
+ const routesRel = posix(relative(appRoot, routesDir))
208
+ for (const route of routeFiles(manifest)) {
209
+ const dir = dirname(route.file)
210
+ const name = route.file.slice(dir === "." ? 0 : dir.length + 1).replace(/\.[^.]+$/, "")
211
+ const out = join(typesRoot, routesRel, dir === "." ? "" : dir, "+types", `${name}.d.ts`)
212
+ files.set(out, routeTypesModule(route, out, routesDir, `${routesRel}/${route.file}`, register))
213
+ }
214
+ return files
215
+ }
216
+
217
+ /** Generated files missing or different on disk, and files on disk the app no longer generates. */
218
+ export function staleRouteTypes(options: RouteTypesOptions): readonly string[] {
219
+ const files = routeTypeFiles(options)
220
+ const stale: string[] = []
221
+ for (const [path, content] of files) {
222
+ if (!existsSync(path) || readFileSync(path, "utf8") !== content) stale.push(path)
223
+ }
224
+ for (const path of onDisk(join(options.appRoot, ROUTE_TYPES_DIR))) {
225
+ if (!files.has(path)) stale.push(path)
226
+ }
227
+ return stale.sort()
228
+ }
229
+
230
+ function onDisk(dir: string): readonly string[] {
231
+ if (!existsSync(dir)) return []
232
+ return (readdirSync(dir, { recursive: true, withFileTypes: true }) as Dirent[])
233
+ .filter((entry) => entry.isFile())
234
+ .map((entry) => join(entry.parentPath, entry.name))
235
+ }
236
+
237
+ export interface RouteTypesResult {
238
+ /** Files written because they were missing or out of date. */
239
+ readonly written: readonly string[]
240
+ /** Files removed because no route generates them any more. */
241
+ readonly removed: readonly string[]
242
+ }
243
+
244
+ /**
245
+ * Bring `.nifra/types` up to date with the app's routes. Unchanged files are left alone, so an
246
+ * editor watching them sees only real changes. Owns the directory: anything else in it is removed.
247
+ */
248
+ export function writeRouteTypes(options: RouteTypesOptions): RouteTypesResult {
249
+ const files = routeTypeFiles(options)
250
+ const written: string[] = []
251
+ const removed: string[] = []
252
+ for (const [path, content] of files) {
253
+ if (existsSync(path) && readFileSync(path, "utf8") === content) continue
254
+ mkdirSync(dirname(path), { recursive: true })
255
+ writeFileSync(path, content)
256
+ written.push(path)
257
+ }
258
+ for (const path of onDisk(join(options.appRoot, ROUTE_TYPES_DIR))) {
259
+ if (files.has(path)) continue
260
+ rmSync(path)
261
+ removed.push(path)
262
+ }
263
+ return { written: written.sort(), removed: removed.sort() }
264
+ }
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 {