@nifrajs/web 2.1.0 → 2.3.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 (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
package/src/router.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The agnostic client-side router core — pure logic, no `window`, no framework. A per-adapter
2
+ * The agnostic client-side router core - pure logic, no `window`, no framework. A per-adapter
3
3
  * Router binding subscribes via `subscribe`/`snapshot` and renders the matched chain; a
4
4
  * browser-only `installHistory` (history + link interception) wires on top. Kept DOM-free so it
5
5
  * unit-tests without a browser and is safe to import from the SSR core's main entry.
@@ -9,7 +9,9 @@ import {
9
9
  compileRoutePattern,
10
10
  matchRoutePattern,
11
11
  } from "@nifrajs/core/pattern"
12
+ import type { StandardSchemaV1 } from "@nifrajs/core/server"
12
13
  import { parseNdjsonData } from "./deferred.ts"
14
+ import { isClientOnlySearchChange } from "./search.ts"
13
15
 
14
16
  /**
15
17
  * Request header that asks a nifra route's GET to return just the loader data as JSON (instead of
@@ -20,32 +22,98 @@ export const DATA_HEADER = "x-nifra-data"
20
22
  /**
21
23
  * Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
22
24
  * The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
23
- * `<path>/_data.json` (a CDN file — no worker round-trip) instead of the dynamic header-GET.
25
+ * `<path>/_data.json` (a CDN file - no worker round-trip) instead of the dynamic header-GET.
24
26
  */
25
27
  export const PRERENDERED_GLOBAL = "__NIFRA_PRERENDERED__"
26
28
 
27
29
  /**
28
30
  * Response header a data-mode action POST uses to convey a redirect (`redirect(...)`) to the
29
- * client — fetch would otherwise silently follow a 3xx to its HTML, losing the target. The
31
+ * client - fetch would otherwise silently follow a 3xx to its HTML, losing the target. The
30
32
  * client reads this and performs a client-side navigation instead.
31
33
  */
32
34
  export const REDIRECT_HEADER = "x-nifra-redirect"
33
35
 
34
36
  /**
35
37
  * Response header an action sets (via the `revalidate(paths, data)` helper) to tell the client which
36
- * routes the mutation changed — a comma-separated list of paths. After the submit, the client marks
38
+ * routes the mutation changed - a comma-separated list of paths. After the submit, the client marks
37
39
  * those cached routes stale (refetching any that are mounted) so a mutation can refresh views beyond
38
40
  * the active one. The client validates each path against the manifest matcher before acting on it.
39
41
  */
40
42
  export const REVALIDATE_HEADER = "x-nifra-revalidate"
41
43
 
44
+ /**
45
+ * Response header carrying a **terminal status** a loader signalled with `notFound()` / `gone()` /
46
+ * `statusPage(n)` during a client-side navigation's data fetch.
47
+ *
48
+ * A soft-nav fetches data, not a document, so the server cannot answer by rendering the `_404` page -
49
+ * it has only JSON to return. Without this channel the two halves disagree: a crawler doing a hard
50
+ * navigation gets a correct 404 page while a user who clicked a link gets an error or a blank screen.
51
+ * The client reads it and renders the same boundary the server would have.
52
+ *
53
+ * Carried in a header rather than the body so it survives the NDJSON deferred-data path unchanged,
54
+ * exactly like {@link REDIRECT_HEADER}.
55
+ */
56
+ export const STATUS_HEADER = "x-nifra-status"
57
+
58
+ /**
59
+ * Layout indices a client navigation is asking the server NOT to re-run, comma separated.
60
+ *
61
+ * The client owns the decision because it holds both the old and new match plus each layout's scoped
62
+ * params. The server treats it as a hint and refuses to skip a `gate` regardless - a guard that runs
63
+ * only when the client says so is not a guard.
64
+ */
65
+ export const RETAIN_HEADER = "x-nifra-retain"
66
+
67
+ /**
68
+ * The path a client navigation is coming FROM, sent on the data-mode GET.
69
+ *
70
+ * Lets the SERVER decide which layout loaders to skip: it already knows each layout's scoped params
71
+ * (derived at build time), so given both paths it can tell whether a layout's own prefix changed.
72
+ * Doing it server-side means the client never needs the scope table, and the decision lives next to
73
+ * the data it is about.
74
+ *
75
+ * Sent only when the client actually holds layout data to retain, so a request without it is simply
76
+ * the full-chain case.
77
+ */
78
+ export const NAV_FROM_HEADER = "x-nifra-from"
79
+
80
+ /**
81
+ * The data-mode payload once a chain can carry layout data.
82
+ *
83
+ * Versioned because a prerendered `_data.json` is a static file on a CDN and outlives the deploy that
84
+ * wrote it: a browser running new client code can be handed an envelope written by the previous
85
+ * build. The reader therefore accepts BOTH the bare pre-envelope value and this shape, and decides by
86
+ * structure rather than by assuming the deploy was atomic.
87
+ */
88
+ export interface RouteDataEnvelope {
89
+ readonly v: 1
90
+ readonly data: unknown
91
+ readonly layoutData?: readonly unknown[]
92
+ /** Indices whose loader was SKIPPED because the layout's own params did not change. The client
93
+ * keeps its existing value at each of these, so an unchanged layout is neither refetched nor lost. */
94
+ readonly retained?: readonly number[]
95
+ /** Terminal status signalled by a loader. Added by the browser transport after reading
96
+ * `X-Nifra-Status`; old servers and static data files simply omit it. */
97
+ readonly status?: number
98
+ }
99
+
100
+ /** Recognise the envelope without mistaking a plain loader object that happens to have a `data` key. */
101
+ export function isRouteDataEnvelope(value: unknown): value is RouteDataEnvelope {
102
+ return (
103
+ typeof value === "object" &&
104
+ value !== null &&
105
+ (value as { v?: unknown }).v === 1 &&
106
+ "data" in value
107
+ )
108
+ }
109
+
42
110
  /** A URL matched against the manifest patterns: which route + its extracted params. */
43
111
  export interface RouteMatch {
44
112
  readonly routeId: string
45
113
  readonly params: Record<string, string>
46
114
  }
47
115
 
48
- /** An in-flight client submit — the action it targets + the `FormData` being sent. Set while the
116
+ /** An in-flight client submit - the action it targets + the `FormData` being sent. Set while the
49
117
  * submit is pending, cleared when it settles. A component reads `submission.formData` to render an
50
118
  * **optimistic** view (the expected result) before the server responds. */
51
119
  export interface Submission {
@@ -60,6 +128,9 @@ export interface RouterState {
60
128
  /** The current URL path (used to revalidate the active loader after an action). */
61
129
  readonly path: string
62
130
  readonly data: unknown
131
+ /** Per-layout loader data, aligned with the matched chain's leading layout prefix. Absent when no
132
+ * layout in that chain has a loader. */
133
+ readonly layoutData?: readonly unknown[] | undefined
63
134
  /** An action's data return after a client-side submit (cleared on navigation). */
64
135
  readonly actionData?: unknown
65
136
  /** True while a navigation or submit is in flight (drives loading UI). */
@@ -67,11 +138,11 @@ export interface RouterState {
67
138
  /** The path a navigation is transitioning TO while `pending` (cleared when it settles). Lets a
68
139
  * `NavLink` know whether its own `to` is the one loading; `undefined` when idle. */
69
140
  readonly pendingPath?: string | undefined
70
- /** The in-flight submit (set during a `submit`, cleared when it settles) — for optimistic UI. */
141
+ /** The in-flight submit (set during a `submit`, cleared when it settles) - for optimistic UI. */
71
142
  readonly submission?: Submission
72
143
  }
73
144
 
74
- /** A route id paired with its nifra pattern (e.g. `":id"` segments) — the matcher input. */
145
+ /** A route id paired with its nifra pattern (e.g. `":id"` segments) - the matcher input. */
75
146
  export interface RoutePattern {
76
147
  readonly routeId: string
77
148
  readonly pattern: string
@@ -89,7 +160,7 @@ export function createMatcher(
89
160
  .map((pattern) => ({ routeId: pattern.routeId, pattern: compileRoutePattern(pattern.pattern) }))
90
161
  .sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
91
162
  return (path) => {
92
- // Strip the query without allocating a `split("?")` array — matcher runs per match.
163
+ // Strip the query without allocating a `split("?")` array - matcher runs per match.
93
164
  const q = path.indexOf("?")
94
165
  const clean = q === -1 ? path : path.slice(0, q)
95
166
  for (const c of compiled) {
@@ -110,6 +181,10 @@ export type FetchRouteData = (
110
181
  path: string,
111
182
  match: RouteMatch,
112
183
  signal?: AbortSignal,
184
+ navigation?: {
185
+ readonly from: string
186
+ readonly retain: readonly number[]
187
+ },
113
188
  ) => Promise<unknown>
114
189
 
115
190
  /** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
@@ -119,7 +194,7 @@ export interface SubmitOptions {
119
194
  readonly revalidate?: boolean
120
195
  }
121
196
 
122
- /** A fetcher's observable state — independent of the main router. `pending` covers its in-flight
197
+ /** A fetcher's observable state - independent of the main router. `pending` covers its in-flight
123
198
  * load/submit; `data` is its last `load()` result; `actionData` its last `submit()` result;
124
199
  * `submission` the in-flight submit (for optimistic UI). Client-only (never SSR'd). */
125
200
  export interface FetcherState {
@@ -131,7 +206,7 @@ export interface FetcherState {
131
206
 
132
207
  /**
133
208
  * An independent load/submit state machine, retrieved by `router.fetcher(key)`. Runs **concurrently**
134
- * with the main router and with other fetchers — each is single-flight against *itself* (its own
209
+ * with the main router and with other fetchers - each is single-flight against *itself* (its own
135
210
  * monotonic token), so N row-level mutations / side-channel loads can be in flight at once without
136
211
  * disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
137
212
  */
@@ -145,7 +220,7 @@ export interface Fetcher {
145
220
  load: (path: string) => Promise<void>
146
221
  /** Submit an action into this fetcher's own state; honors `X-Nifra-Revalidate` by refreshing the
147
222
  * active route + any mounted fetcher showing a changed path. Rejects on failure (caller falls back).
148
- * (No `revalidate` opt-out — a fetcher has no active loader of its own to skip.) */
223
+ * (No `revalidate` opt-out - a fetcher has no active loader of its own to skip.) */
149
224
  submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
150
225
  }
151
226
 
@@ -161,7 +236,7 @@ export interface ClientRouter {
161
236
  * Submit an action (POST `body` to `action` in data mode): a redirect becomes a client
162
237
  * navigation; otherwise the data return is published as `actionData` and the active route's
163
238
  * loader is revalidated so the mutation is reflected. Pass `{ revalidate: false }` to SKIP that
164
- * revalidation — keep the current `data` and just publish the action's `actionData` (useful when
239
+ * revalidation - keep the current `data` and just publish the action's `actionData` (useful when
165
240
  * the action already returned everything that changed, saving the extra round-trip). A redirect
166
241
  * always loads its target regardless. Rejects on failure (caller falls back).
167
242
  */
@@ -173,28 +248,28 @@ export interface ClientRouter {
173
248
  /**
174
249
  * Mark cached route data stale and refresh the active view. With `paths`, target exactly those
175
250
  * (e.g. the routes a mutation changed); without, invalidate the whole cache. The active route
176
- * refreshes immediately — refetched + republished — whenever it's in scope (an explicit list that
251
+ * refreshes immediately - refetched + republished - whenever it's in scope (an explicit list that
177
252
  * includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
178
253
  * fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
179
254
  * The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
180
255
  */
181
256
  invalidate: (paths?: readonly string[]) => Promise<void>
182
257
  /**
183
- * Warm a path's chunk + loader data into a bounded one-shot cache without publishing state —
258
+ * Warm a path's chunk + loader data into a bounded one-shot cache without publishing state -
184
259
  * a later `navigate` to it transitions with no network round-trip. Best-effort: failures and
185
260
  * unmatched paths are no-ops. Wired to link hover/focus by `installHistory`.
186
261
  */
187
262
  prefetch: (path: string) => Promise<void>
188
263
  /**
189
- * Get (lazily creating) the stable {@link Fetcher} for `key` — an independent, concurrent
264
+ * Get (lazily creating) the stable {@link Fetcher} for `key` - an independent, concurrent
190
265
  * load/submit state machine for row-level mutations or side-channel loads that must not disturb
191
266
  * the active view. The same `key` always returns the same fetcher (so a binding can subscribe to a
192
267
  * stable store). Keys are app-chosen and typically stable (e.g. a row id).
193
268
  */
194
269
  fetcher: (key: string) => Fetcher
195
- /** All live fetchers — for a global busy view (e.g. a `useFetchers` binding). */
270
+ /** All live fetchers - for a global busy view (e.g. a `useFetchers` binding). */
196
271
  fetchers: () => readonly Fetcher[]
197
- /** Subscribe to any-fetcher-changed (a transition on any fetcher, or a new one created) — backs a
272
+ /** Subscribe to any-fetcher-changed (a transition on any fetcher, or a new one created) - backs a
198
273
  * `useFetchers` binding; returns an unsubscribe fn. */
199
274
  subscribeFetchers: (listener: () => void) => () => void
200
275
  /** Match a path against the manifest patterns (exposed for history/link wiring). */
@@ -209,6 +284,15 @@ export interface ClientRouterOptions {
209
284
  /** Ensure a route's code chunk is loaded before rendering (code-splitting). Awaited in parallel
210
285
  * with the loader data, so `pending` covers both. Omit when the bundle isn't split. */
211
286
  readonly loadModule?: (routeId: string) => Promise<void>
287
+ /** Terminal status → client route id. Generated entries populate this from `_404` and
288
+ * `_<status>` files so soft navigation renders the same boundary as a hard request. */
289
+ readonly statusRoutes?: Readonly<Record<number, string>>
290
+ /** routeId → the route's client-only search keys (its `searchClientKeys` export). When a soft
291
+ * navigation stays on the same route + pathname and changes ONLY these keys, `navigate` publishes the
292
+ * new URL WITHOUT re-running the loader (re-render, not revalidate). Populated lazily by the generated
293
+ * entry's `loadModule` (so a route's keys are present once it has been visited, which is exactly when a
294
+ * same-route nav can consult them). Omit ⇒ every search change revalidates (the safe default). */
295
+ readonly searchClientKeys?: Readonly<Record<string, readonly string[]>>
212
296
  }
213
297
 
214
298
  /** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
@@ -216,13 +300,20 @@ export interface MountRouterOptions {
216
300
  readonly router: ClientRouter
217
301
  /** routeId → layout chain (outermost layout → page); built by `generateClientEntry`. */
218
302
  readonly routes: Record<string, readonly unknown[]>
219
- /** Hydration container (opaque — the adapter casts it to its DOM element type). */
303
+ /** routeId → the route's search-schema CHAIN (its layout chain's `searchSchema` exports, outermost
304
+ * first, then the page's; each entry `undefined` when that module declares none); built by
305
+ * `generateClientEntry`. The mount derives each route's typed `search` from this chain plus the URL via
306
+ * `searchOfChain` (layout keys merged with page keys, page-wins), matching the server's
307
+ * `ctx.search`/`RenderProps.search`. Omitted by callers with no typed search (tests, a hand-built mount)
308
+ * ⇒ every route sees the raw parsed query. */
309
+ readonly searchSchemas?: Readonly<Record<string, readonly (StandardSchemaV1 | undefined)[]>>
310
+ /** Hydration container (opaque - the adapter casts it to its DOM element type). */
220
311
  readonly container: unknown
221
312
  }
222
313
 
223
314
  /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
224
315
  * `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
225
- * navigation fetches and action submits — both transports are identical. */
316
+ * navigation fetches and action submits - both transports are identical. */
226
317
  const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown> =>
227
318
  (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
228
319
  ? parseNdjsonData(res.body, signal)
@@ -233,8 +324,8 @@ const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown>
233
324
  const dataUrlFor = (pathname: string): string =>
234
325
  pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
235
326
 
236
- const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
237
- // SSG fast path: if this path was prerendered, its loader data is a static file — fetch that (no
327
+ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
328
+ // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
238
329
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
239
330
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
240
331
  const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
@@ -245,7 +336,16 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
245
336
  if (staticRes.ok) return readResponseData(staticRes, signal)
246
337
  }
247
338
  }
248
- const res = await fetch(path, { headers: { [DATA_HEADER]: "1" }, signal: signal ?? null })
339
+ const headers = new Headers({ [DATA_HEADER]: "1" })
340
+ if (navigation !== undefined && navigation.retain.length > 0) {
341
+ headers.set(RETAIN_HEADER, navigation.retain.join(","))
342
+ headers.set(NAV_FROM_HEADER, navigation.from)
343
+ }
344
+ const res = await fetch(path, { headers, signal: signal ?? null })
345
+ const status = Number(res.headers.get(STATUS_HEADER))
346
+ if (Number.isInteger(status) && status >= 400 && status <= 599) {
347
+ return { v: 1, data: null, status } satisfies RouteDataEnvelope
348
+ }
249
349
  if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
250
350
  return readResponseData(res, signal)
251
351
  }
@@ -255,9 +355,72 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
255
355
  * navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
256
356
  * A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
257
357
  */
358
+ // Split a `pathname + search` into its two parts. `rawSearchOf` keeps the leading `?` (what
359
+ // `parseSearch` accepts); an empty query yields `""`.
360
+ const pathnameOf = (path: string): string => {
361
+ const q = path.indexOf("?")
362
+ return q === -1 ? path : path.slice(0, q)
363
+ }
364
+ const rawSearchOf = (path: string): string => {
365
+ const q = path.indexOf("?")
366
+ return q === -1 ? "" : path.slice(q)
367
+ }
368
+
258
369
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
259
370
  const match = createMatcher(options.patterns)
260
371
  const fetchData = options.fetchData ?? defaultFetchData
372
+ // routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
373
+ // to it as routes load), so a same-route nav always sees the current route's keys.
374
+ const searchClientKeys = options.searchClientKeys ?? {}
375
+ /**
376
+ * Fetch a route's data and normalise it.
377
+ *
378
+ * The payload is either the versioned envelope (a chain with layout loaders) or the bare loader
379
+ * value (everything else, and every response written before layout loaders existed). Deciding by
380
+ * structure rather than by version-of-the-deploy is what lets a prerendered `_data.json` written by
381
+ * an older build be read by newer client code.
382
+ */
383
+ type LoadedRouteData = {
384
+ readonly data: unknown
385
+ readonly layoutData?: readonly unknown[] | undefined
386
+ readonly terminalRouteId?: string | undefined
387
+ }
388
+ const loadRouteData = async (
389
+ path: string,
390
+ match: { routeId: string; params: Record<string, string> },
391
+ signal?: AbortSignal,
392
+ retainFrom?: { readonly path: string; readonly layoutData: readonly unknown[] },
393
+ ): Promise<LoadedRouteData> => {
394
+ const retain = retainFrom?.layoutData.map((_, index) => index) ?? []
395
+ const payload = await fetchData(
396
+ path,
397
+ match,
398
+ signal,
399
+ retainFrom === undefined ? undefined : { from: retainFrom.path, retain },
400
+ )
401
+ if (!isRouteDataEnvelope(payload)) return { data: payload }
402
+ if (payload.status !== undefined) {
403
+ const terminalRouteId = options.statusRoutes?.[payload.status] ?? options.statusRoutes?.[404]
404
+ if (terminalRouteId === undefined) {
405
+ throw new Error(
406
+ `[nifra/web] no client status boundary is available for terminal status ${payload.status}`,
407
+ )
408
+ }
409
+ return { data: null, terminalRouteId }
410
+ }
411
+ if (retainFrom === undefined || payload.retained === undefined) {
412
+ return { data: payload.data, layoutData: payload.layoutData }
413
+ }
414
+ const retained = new Set(
415
+ payload.retained.filter(
416
+ (index) => Number.isInteger(index) && index >= 0 && index < retainFrom.layoutData.length,
417
+ ),
418
+ )
419
+ const layoutData = payload.layoutData?.map((value, index) =>
420
+ retained.has(index) ? retainFrom.layoutData[index] : value,
421
+ )
422
+ return { data: payload.data, layoutData }
423
+ }
261
424
  const loadModule = options.loadModule
262
425
  let state = options.initial
263
426
  const listeners = new Set<() => void>()
@@ -272,21 +435,26 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
272
435
  // Bounded one-shot prefetch cache (path → loader data) + an in-flight guard so hover spam
273
436
  // doesn't double-fetch. Consumed (and dropped) by the next navigate to that path.
274
437
  const MAX_PREFETCH = 10
275
- const prefetched = new Map<string, unknown>()
438
+ const prefetched = new Map<string, LoadedRouteData>()
276
439
  const inflight = new Set<string>()
277
440
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
278
441
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
279
- // increments). Bounded — evict the oldest-inserted past the cap (route data is small; this just
442
+ // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
280
443
  // caps memory). `status` is the staleness ledger: `invalidate` flips entries to `stale`; readers
281
- // refetch stale ones. Client-only — never serialized/hydrated.
444
+ // refetch stale ones. Client-only - never serialized/hydrated.
282
445
  const MAX_CACHE = 50
283
- const cache = new Map<string, { data: unknown; status: "fresh" | "stale" }>()
284
- const cachePut = (path: string, data: unknown): void => {
446
+ const cache = new Map<
447
+ string,
448
+ { data: unknown; layoutData?: readonly unknown[] | undefined; status: "fresh" | "stale" }
449
+ >()
450
+ const cachePut = (path: string, loaded: LoadedRouteData): void => {
285
451
  if (!cache.has(path) && cache.size >= MAX_CACHE) {
286
452
  const oldest = cache.keys().next().value
287
453
  if (oldest !== undefined) cache.delete(oldest)
288
454
  }
289
- cache.set(path, { data, status: "fresh" })
455
+ // The PAIR, not just the value: navigating back to a cached route must restore its layouts' data
456
+ // too, or they would render empty on a hit and populated on a miss.
457
+ cache.set(path, { data: loaded.data, layoutData: loaded.layoutData, status: "fresh" })
290
458
  }
291
459
  // Flip cached entries to stale (a no-op for paths not in the cache). Shared by `invalidate` and the
292
460
  // `X-Nifra-Revalidate` handling in `submit`.
@@ -297,7 +465,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
297
465
  }
298
466
  }
299
467
  // Parse the `X-Nifra-Revalidate` response header into validated paths. The header is response data
300
- // (a trust boundary), so each path must match a real manifest pattern — unknown/garbage is dropped
468
+ // (a trust boundary), so each path must match a real manifest pattern - unknown/garbage is dropped
301
469
  // and never triggers a fetch.
302
470
  const parseRevalidate = (header: string | null): string[] =>
303
471
  header === null
@@ -320,14 +488,14 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
320
488
  state = { ...state, pending: true }
321
489
  emit()
322
490
  try {
323
- const data = await fetchData(
491
+ const loaded = await loadRouteData(
324
492
  state.path,
325
493
  { routeId: state.routeId, params: state.params },
326
494
  ac.signal,
327
495
  )
328
- if (mine !== token) return // superseded — drop the stale result
329
- cachePut(state.path, data)
330
- state = { ...state, data, pending: false }
496
+ if (mine !== token) return // superseded - drop the stale result
497
+ cachePut(state.path, loaded)
498
+ state = { ...state, data: loaded.data, layoutData: loaded.layoutData, pending: false }
331
499
  emit()
332
500
  } catch (err) {
333
501
  if (mine === token) {
@@ -359,7 +527,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
359
527
 
360
528
  // Best-effort refresh of every MOUNTED reader of `paths`: the active route (unless `skipActive`,
361
529
  // when the caller already refetched it) + any fetcher that loaded one of these paths. Errors are
362
- // swallowed — a failed targeted refresh must not fail the mutation that triggered it.
530
+ // swallowed - a failed targeted refresh must not fail the mutation that triggered it.
363
531
  const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
364
532
  const jobs: Promise<void>[] = []
365
533
  if (!skipActive && paths.includes(state.path)) jobs.push(refetchActive().catch(() => {}))
@@ -387,17 +555,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
387
555
  fState = { ...fState, pending: true }
388
556
  fEmit()
389
557
  try {
390
- const [, data] = await Promise.all([
558
+ const [, loaded] = await Promise.all([
391
559
  loadModule?.(matched.routeId),
392
- fetchData(path, matched, ac.signal),
560
+ loadRouteData(path, matched, ac.signal),
393
561
  ])
394
562
  if (mine !== fToken) return // a newer load/submit on THIS fetcher superseded us
395
- // Record the loaded path only on SUCCESS — a thrown or superseded load must not
563
+ // Record the loaded path only on SUCCESS - a thrown or superseded load must not
396
564
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
397
565
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
398
566
  loadedPath = path
399
- cachePut(path, data)
400
- fState = { ...fState, data, pending: false }
567
+ cachePut(path, loaded)
568
+ fState = { ...fState, data: loaded.data, pending: false }
401
569
  fEmit()
402
570
  } catch (err) {
403
571
  if (mine === fToken) {
@@ -418,7 +586,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
418
586
  load: runLoad,
419
587
  submit: async (action, body) => {
420
588
  const mine = ++fToken
421
- // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) — like
589
+ // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) - like
422
590
  // `runLoad` does for a superseding load. The mutation POST is left to complete; the
423
591
  // signal cancels the follow-up data drain if a newer op supersedes this one.
424
592
  fAbort?.abort()
@@ -442,7 +610,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
442
610
  // Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
443
611
  fState = { pending: false, data: fState.data, actionData }
444
612
  fEmit()
445
- // A fetcher has no loader of its own — its mutation's freshness flows through the cache:
613
+ // A fetcher has no loader of its own - its mutation's freshness flows through the cache:
446
614
  // mark the changed routes stale + refresh every mounted reader (active route + fetchers).
447
615
  if (changed.length > 0) {
448
616
  markStale(changed)
@@ -479,6 +647,27 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
479
647
  navigate: async (path) => {
480
648
  const matched = match(path)
481
649
  if (matched === null) return
650
+ // Client-only search fast path: same route + same pathname, and every changed search key is
651
+ // declared client-only. Publish the new URL (so `useSearch`/`useLocation` update) WITHOUT a data
652
+ // fetch - the loader's inputs did not change. Any loader-affecting key change falls through to the
653
+ // normal match → fetch → publish below, so data is never stale. Abort an in-flight fetch first: a
654
+ // rapid `?tab` after a real nav must not let the older fetch overwrite this synchronous update.
655
+ if (
656
+ matched.routeId === state.routeId &&
657
+ pathnameOf(path) === pathnameOf(state.path) &&
658
+ isClientOnlySearchChange(
659
+ rawSearchOf(state.path),
660
+ rawSearchOf(path),
661
+ searchClientKeys[state.routeId] ?? [],
662
+ )
663
+ ) {
664
+ ++token // supersede any in-flight navigation so its late result is dropped
665
+ navAbort?.abort()
666
+ cachePut(path, { data: state.data, layoutData: state.layoutData })
667
+ state = { ...state, path, pending: false, pendingPath: undefined }
668
+ emit()
669
+ return
670
+ }
482
671
  const mine = ++token
483
672
  navAbort?.abort() // abandon any in-flight navigation's stream
484
673
  const ac = new AbortController()
@@ -486,27 +675,36 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
486
675
  state = { ...state, pending: true, pendingPath: path }
487
676
  emit()
488
677
  try {
489
- // Use prefetched data when present (one-shot — drop it); else fetch. The chunk is loaded
678
+ // Use prefetched data when present (one-shot - drop it); else fetch. The chunk is loaded
490
679
  // either way (cached + instant after a prefetch), so pending covers both.
491
680
  const hit = prefetched.has(path)
492
681
  const dataPromise = hit
493
- ? Promise.resolve(prefetched.get(path))
494
- : fetchData(path, matched, ac.signal)
682
+ ? Promise.resolve(prefetched.get(path) as LoadedRouteData)
683
+ : loadRouteData(
684
+ path,
685
+ matched,
686
+ ac.signal,
687
+ state.layoutData === undefined
688
+ ? undefined
689
+ : { path: state.path, layoutData: state.layoutData },
690
+ )
495
691
  if (hit) prefetched.delete(path)
496
- const [, data] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
497
- if (mine !== token) return // a newer navigation superseded this one — drop the stale result
498
- cachePut(path, data) // keep the keyed cache coherent with what we publish
692
+ const [, loaded] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
693
+ if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
694
+ if (mine !== token) return // a newer navigation superseded this one - drop the stale result
695
+ cachePut(path, loaded) // keep the keyed cache coherent with what we publish
499
696
  state = {
500
- routeId: matched.routeId,
501
- params: matched.params,
697
+ routeId: loaded.terminalRouteId ?? matched.routeId,
698
+ params: loaded.terminalRouteId === undefined ? matched.params : {},
502
699
  path,
503
- data,
700
+ data: loaded.data,
701
+ layoutData: loaded.layoutData,
504
702
  actionData: undefined, // a fresh navigation has no action result
505
703
  pending: false,
506
704
  }
507
705
  emit()
508
706
  } catch (err) {
509
- // Clear our pending flag + target (only if still current) and rethrow — the caller decides how
707
+ // Clear our pending flag + target (only if still current) and rethrow - the caller decides how
510
708
  // to recover (the history layer falls back to a full-page navigation).
511
709
  if (mine === token) {
512
710
  state = { ...state, pending: false, pendingPath: undefined }
@@ -518,7 +716,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
518
716
  submit: async (action, body, opts) => {
519
717
  const mine = ++token
520
718
  // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
521
- // fetch + their NDJSON drains) — not the mutation POST itself, which should complete.
719
+ // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
522
720
  // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
523
721
  navAbort?.abort()
524
722
  const ac = new AbortController()
@@ -536,58 +734,72 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
536
734
  if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
537
735
  const redirectTo = res.headers.get(REDIRECT_HEADER)
538
736
  if (redirectTo !== null) {
539
- // The action redirected (Post/Redirect/Get) — treat it as a client navigation.
737
+ // The action redirected (Post/Redirect/Get) - treat it as a client navigation.
540
738
  const target = match(redirectTo)
541
739
  if (target === null)
542
740
  throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
543
- const [, data] = await Promise.all([
741
+ const [, loaded] = await Promise.all([
544
742
  loadModule?.(target.routeId),
545
- fetchData(redirectTo, target, ac.signal),
743
+ loadRouteData(
744
+ redirectTo,
745
+ target,
746
+ ac.signal,
747
+ state.layoutData === undefined
748
+ ? undefined
749
+ : { path: state.path, layoutData: state.layoutData },
750
+ ),
546
751
  ])
752
+ if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
547
753
  if (mine !== token) return
548
- cachePut(redirectTo, data)
754
+ cachePut(redirectTo, loaded)
549
755
  state = {
550
- routeId: target.routeId,
551
- params: target.params,
756
+ routeId: loaded.terminalRouteId ?? target.routeId,
757
+ params: loaded.terminalRouteId === undefined ? target.params : {},
552
758
  path: redirectTo,
553
- data,
759
+ data: loaded.data,
760
+ layoutData: loaded.layoutData,
554
761
  actionData: undefined,
555
762
  pending: false,
556
763
  }
557
764
  emit()
558
765
  return
559
766
  }
560
- // The action's data — streamed NDJSON if it `defer()`'d slow parts (markers settle as lines
767
+ // The action's data - streamed NDJSON if it `defer()`'d slow parts (markers settle as lines
561
768
  // arrive, for `<Await actionData>`), else one JSON. Abortable so a superseding nav cancels the
562
769
  // drain.
563
770
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
564
771
  // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
565
772
  // validated against the manifest.
566
773
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
567
- // Revalidate the active route's loader so the mutation is reflected — unless the caller opted
774
+ // Revalidate the active route's loader so the mutation is reflected - unless the caller opted
568
775
  // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
569
776
  // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
570
777
  const skipActive = opts?.revalidate === false && !changed.includes(state.path)
571
- const data = skipActive
572
- ? state.data
573
- : await fetchData(state.path, { routeId: state.routeId, params: state.params }, ac.signal)
778
+ const loaded = skipActive
779
+ ? { data: state.data, layoutData: state.layoutData }
780
+ : await loadRouteData(
781
+ state.path,
782
+ { routeId: state.routeId, params: state.params },
783
+ ac.signal,
784
+ )
574
785
  if (mine !== token) return
575
- cachePut(state.path, data) // the revalidated (or kept) data is now the cache's truth
786
+ cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
576
787
  // Mark the OTHER changed routes stale so the next access refetches.
577
788
  markStale(changed.filter((p) => p !== state.path))
578
789
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
579
- // window is over — the real data now drives the view).
790
+ // window is over - the real data now drives the view).
580
791
  state = {
581
792
  routeId: state.routeId,
582
793
  params: state.params,
583
794
  path: state.path,
584
- data,
795
+ data: loaded.data,
796
+ layoutData: loaded.layoutData,
585
797
  actionData,
586
798
  pending: false,
587
799
  }
588
800
  emit()
589
801
  // Refresh any mounted fetcher showing one of the changed routes (the active route was just
590
- // revalidated inline above, so skip it here). Best-effort — never rejects the submit.
802
+ // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
591
803
  await refreshMounted(changed, true)
592
804
  } catch (err) {
593
805
  if (mine === token) {
@@ -607,11 +819,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
607
819
  }
608
820
  },
609
821
  invalidate: async (paths) => {
610
- // Mark targeted cache entries stale (all entries when no `paths`) — unmounted ones refetch
822
+ // Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
611
823
  // lazily on next access.
612
824
  const targets = paths ?? [...cache.keys()]
613
825
  markStale(targets)
614
- // Refresh mounted readers now: the active route (when in scope — an explicit list including it,
826
+ // Refresh mounted readers now: the active route (when in scope - an explicit list including it,
615
827
  // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
616
828
  // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
617
829
  const jobs: Promise<void>[] = []
@@ -625,15 +837,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
625
837
  if (matched === null) return
626
838
  inflight.add(path)
627
839
  try {
628
- const [, data] = await Promise.all([
840
+ // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
841
+ // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
842
+ const [, loaded] = await Promise.all([
629
843
  loadModule?.(matched.routeId),
630
- fetchData(path, matched),
844
+ loadRouteData(path, matched),
631
845
  ])
632
846
  if (prefetched.size >= MAX_PREFETCH) {
633
847
  const oldest = prefetched.keys().next().value
634
848
  if (oldest !== undefined) prefetched.delete(oldest)
635
849
  }
636
- prefetched.set(path, data)
850
+ prefetched.set(path, loaded)
637
851
  } catch {
638
852
  // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
639
853
  } finally {
@@ -645,7 +859,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
645
859
  if (existing !== undefined) return existing.fetcher
646
860
  const entry = createFetcher()
647
861
  fetchers.set(key, entry)
648
- emitFetchers() // a new fetcher appeared — wake any `useFetchers` subscriber
862
+ emitFetchers() // a new fetcher appeared - wake any `useFetchers` subscriber
649
863
  return entry.fetcher
650
864
  },
651
865
  fetchers: () => fetchersArr,