@nifrajs/web 2.2.0 → 2.4.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 (179) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-diagnostics.d.ts +20 -0
  23. package/dist/dev-diagnostics.d.ts.map +1 -0
  24. package/dist/dev-diagnostics.js +34 -0
  25. package/dist/dev-diagnostics.js.map +1 -0
  26. package/dist/dev-error.d.ts +10 -13
  27. package/dist/dev-error.d.ts.map +1 -1
  28. package/dist/dev-error.js +51 -37
  29. package/dist/dev-error.js.map +1 -1
  30. package/dist/dev-port.d.ts +3 -3
  31. package/dist/dev-port.d.ts.map +1 -1
  32. package/dist/dev-port.js +10 -4
  33. package/dist/dev-port.js.map +1 -1
  34. package/dist/dev.d.ts +4 -4
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +28 -12
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic.d.ts +93 -0
  39. package/dist/diagnostic.d.ts.map +1 -0
  40. package/dist/diagnostic.js +205 -0
  41. package/dist/diagnostic.js.map +1 -0
  42. package/dist/draft.d.ts +12 -12
  43. package/dist/draft.js +11 -11
  44. package/dist/fn-state.d.ts +56 -0
  45. package/dist/fn-state.d.ts.map +1 -0
  46. package/dist/fn-state.js +77 -0
  47. package/dist/fn-state.js.map +1 -0
  48. package/dist/fn.d.ts +111 -0
  49. package/dist/fn.d.ts.map +1 -0
  50. package/dist/fn.js +125 -0
  51. package/dist/fn.js.map +1 -0
  52. package/dist/fonts-google.d.ts +9 -9
  53. package/dist/fonts-google.js +10 -10
  54. package/dist/fonts.d.ts +7 -7
  55. package/dist/fonts.js +1 -1
  56. package/dist/forms.d.ts +8 -8
  57. package/dist/fs.d.ts +1 -1
  58. package/dist/fs.js +1 -1
  59. package/dist/index.d.ts +151 -70
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +317 -139
  62. package/dist/index.js.map +1 -1
  63. package/dist/internal/head-attributes.d.ts +15 -0
  64. package/dist/internal/head-attributes.d.ts.map +1 -0
  65. package/dist/internal/head-attributes.js +99 -0
  66. package/dist/internal/head-attributes.js.map +1 -0
  67. package/dist/internal/script-types.d.ts +14 -0
  68. package/dist/internal/script-types.d.ts.map +1 -0
  69. package/dist/internal/script-types.js +17 -0
  70. package/dist/internal/script-types.js.map +1 -0
  71. package/dist/internal/server-boundary.d.ts +60 -0
  72. package/dist/internal/server-boundary.d.ts.map +1 -0
  73. package/dist/internal/server-boundary.js +174 -0
  74. package/dist/internal/server-boundary.js.map +1 -0
  75. package/dist/internal/timing-safe-equal.d.ts +1 -1
  76. package/dist/internal/timing-safe-equal.js +1 -1
  77. package/dist/internal/vite-import.d.ts +63 -0
  78. package/dist/internal/vite-import.d.ts.map +1 -0
  79. package/dist/internal/vite-import.js +94 -0
  80. package/dist/internal/vite-import.js.map +1 -0
  81. package/dist/islands.d.ts +5 -5
  82. package/dist/islands.js +5 -5
  83. package/dist/isr.d.ts +15 -15
  84. package/dist/isr.js +7 -7
  85. package/dist/manifest.d.ts +73 -35
  86. package/dist/manifest.d.ts.map +1 -1
  87. package/dist/manifest.js +10 -16
  88. package/dist/manifest.js.map +1 -1
  89. package/dist/module-graph.d.ts +5 -5
  90. package/dist/module-graph.js +4 -4
  91. package/dist/navigation.d.ts +120 -5
  92. package/dist/navigation.d.ts.map +1 -1
  93. package/dist/navigation.js +37 -5
  94. package/dist/navigation.js.map +1 -1
  95. package/dist/plugins/css-modules.d.ts +3 -3
  96. package/dist/plugins/css-modules.js +0 -0
  97. package/dist/plugins/kit.d.ts +8 -8
  98. package/dist/plugins/kit.js +9 -9
  99. package/dist/plugins/scss.js +3 -3
  100. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  101. package/dist/plugins/vite-leak-guard.js +1 -1
  102. package/dist/plugins/vite-server-fn.d.ts +23 -0
  103. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  104. package/dist/plugins/vite-server-fn.js +51 -0
  105. package/dist/plugins/vite-server-fn.js.map +1 -0
  106. package/dist/plugins/vite-server-only.d.ts +49 -0
  107. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  108. package/dist/plugins/vite-server-only.js +57 -0
  109. package/dist/plugins/vite-server-only.js.map +1 -0
  110. package/dist/prerender.d.ts +5 -5
  111. package/dist/prerender.js +7 -7
  112. package/dist/query.d.ts +10 -10
  113. package/dist/query.js +15 -15
  114. package/dist/route-manifest.d.ts +1 -1
  115. package/dist/route-manifest.js +2 -2
  116. package/dist/router.d.ts +30 -21
  117. package/dist/router.d.ts.map +1 -1
  118. package/dist/router.js +62 -32
  119. package/dist/router.js.map +1 -1
  120. package/dist/search.d.ts +94 -0
  121. package/dist/search.d.ts.map +1 -0
  122. package/dist/search.js +232 -0
  123. package/dist/search.js.map +1 -0
  124. package/dist/server-only.d.ts +6 -6
  125. package/dist/server-only.js +7 -7
  126. package/dist/service-worker.d.ts +56 -0
  127. package/dist/service-worker.d.ts.map +1 -0
  128. package/dist/service-worker.js +127 -0
  129. package/dist/service-worker.js.map +1 -0
  130. package/dist/vite.d.ts +16 -14
  131. package/dist/vite.d.ts.map +1 -1
  132. package/dist/vite.js +51 -24
  133. package/dist/vite.js.map +1 -1
  134. package/package.json +38 -4
  135. package/server-manifest.d.ts +31 -0
  136. package/src/build-vite.ts +24 -77
  137. package/src/build.ts +137 -105
  138. package/src/bun-dev-entry.ts +1 -1
  139. package/src/chunk-names.ts +1 -1
  140. package/src/client.ts +215 -32
  141. package/src/conformance.ts +3 -3
  142. package/src/deferred.ts +17 -17
  143. package/src/dev-diagnostics.ts +47 -0
  144. package/src/dev-error.ts +64 -33
  145. package/src/dev-port.ts +17 -5
  146. package/src/dev.ts +36 -20
  147. package/src/diagnostic.ts +284 -0
  148. package/src/draft.ts +15 -15
  149. package/src/fn-state.ts +107 -0
  150. package/src/fn.ts +199 -0
  151. package/src/fonts-google.ts +13 -13
  152. package/src/fonts.ts +7 -7
  153. package/src/forms.ts +8 -8
  154. package/src/fs.ts +2 -2
  155. package/src/index.ts +460 -181
  156. package/src/internal/head-attributes.ts +109 -0
  157. package/src/internal/script-types.ts +18 -0
  158. package/src/internal/server-boundary.ts +187 -0
  159. package/src/internal/timing-safe-equal.ts +1 -1
  160. package/src/internal/vite-import.ts +92 -0
  161. package/src/islands.ts +7 -7
  162. package/src/isr.ts +17 -17
  163. package/src/manifest.ts +83 -42
  164. package/src/module-graph.ts +6 -6
  165. package/src/navigation.ts +151 -5
  166. package/src/plugins/css-modules.ts +0 -0
  167. package/src/plugins/kit.ts +11 -11
  168. package/src/plugins/scss.ts +3 -3
  169. package/src/plugins/vite-leak-guard.ts +2 -2
  170. package/src/plugins/vite-server-fn.ts +67 -0
  171. package/src/plugins/vite-server-only.ts +75 -0
  172. package/src/prerender.ts +10 -10
  173. package/src/query.ts +22 -22
  174. package/src/route-manifest.ts +3 -3
  175. package/src/router.ts +98 -45
  176. package/src/search.ts +297 -0
  177. package/src/server-only.ts +7 -7
  178. package/src/service-worker.ts +165 -0
  179. package/src/vite.ts +61 -32
package/src/router.ts CHANGED
@@ -1,15 +1,17 @@
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.
6
6
  */
7
7
  import {
8
- compareRoutePatternSpecificity,
9
8
  compileRoutePattern,
10
9
  matchRoutePattern,
10
+ sortRoutesBySpecificity,
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,20 +22,20 @@ 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
  */
@@ -111,7 +113,7 @@ export interface RouteMatch {
111
113
  readonly params: Record<string, string>
112
114
  }
113
115
 
114
- /** 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
115
117
  * submit is pending, cleared when it settles. A component reads `submission.formData` to render an
116
118
  * **optimistic** view (the expected result) before the server responds. */
117
119
  export interface Submission {
@@ -136,11 +138,11 @@ export interface RouterState {
136
138
  /** The path a navigation is transitioning TO while `pending` (cleared when it settles). Lets a
137
139
  * `NavLink` know whether its own `to` is the one loading; `undefined` when idle. */
138
140
  readonly pendingPath?: string | undefined
139
- /** 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. */
140
142
  readonly submission?: Submission
141
143
  }
142
144
 
143
- /** 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. */
144
146
  export interface RoutePattern {
145
147
  readonly routeId: string
146
148
  readonly pattern: string
@@ -154,11 +156,14 @@ export interface RoutePattern {
154
156
  export function createMatcher(
155
157
  patterns: readonly RoutePattern[],
156
158
  ): (path: string) => RouteMatch | null {
157
- const compiled = patterns
158
- .map((pattern) => ({ routeId: pattern.routeId, pattern: compileRoutePattern(pattern.pattern) }))
159
- .sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
159
+ const compiled = sortRoutesBySpecificity(
160
+ patterns.map((pattern) => ({
161
+ routeId: pattern.routeId,
162
+ pattern: compileRoutePattern(pattern.pattern),
163
+ })),
164
+ )
160
165
  return (path) => {
161
- // Strip the query without allocating a `split("?")` array — matcher runs per match.
166
+ // Strip the query without allocating a `split("?")` array - matcher runs per match.
162
167
  const q = path.indexOf("?")
163
168
  const clean = q === -1 ? path : path.slice(0, q)
164
169
  for (const c of compiled) {
@@ -192,7 +197,7 @@ export interface SubmitOptions {
192
197
  readonly revalidate?: boolean
193
198
  }
194
199
 
195
- /** A fetcher's observable state — independent of the main router. `pending` covers its in-flight
200
+ /** A fetcher's observable state - independent of the main router. `pending` covers its in-flight
196
201
  * load/submit; `data` is its last `load()` result; `actionData` its last `submit()` result;
197
202
  * `submission` the in-flight submit (for optimistic UI). Client-only (never SSR'd). */
198
203
  export interface FetcherState {
@@ -204,7 +209,7 @@ export interface FetcherState {
204
209
 
205
210
  /**
206
211
  * An independent load/submit state machine, retrieved by `router.fetcher(key)`. Runs **concurrently**
207
- * with the main router and with other fetchers — each is single-flight against *itself* (its own
212
+ * with the main router and with other fetchers - each is single-flight against *itself* (its own
208
213
  * monotonic token), so N row-level mutations / side-channel loads can be in flight at once without
209
214
  * disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
210
215
  */
@@ -218,7 +223,7 @@ export interface Fetcher {
218
223
  load: (path: string) => Promise<void>
219
224
  /** Submit an action into this fetcher's own state; honors `X-Nifra-Revalidate` by refreshing the
220
225
  * active route + any mounted fetcher showing a changed path. Rejects on failure (caller falls back).
221
- * (No `revalidate` opt-out — a fetcher has no active loader of its own to skip.) */
226
+ * (No `revalidate` opt-out - a fetcher has no active loader of its own to skip.) */
222
227
  submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
223
228
  }
224
229
 
@@ -234,7 +239,7 @@ export interface ClientRouter {
234
239
  * Submit an action (POST `body` to `action` in data mode): a redirect becomes a client
235
240
  * navigation; otherwise the data return is published as `actionData` and the active route's
236
241
  * loader is revalidated so the mutation is reflected. Pass `{ revalidate: false }` to SKIP that
237
- * revalidation — keep the current `data` and just publish the action's `actionData` (useful when
242
+ * revalidation - keep the current `data` and just publish the action's `actionData` (useful when
238
243
  * the action already returned everything that changed, saving the extra round-trip). A redirect
239
244
  * always loads its target regardless. Rejects on failure (caller falls back).
240
245
  */
@@ -246,28 +251,28 @@ export interface ClientRouter {
246
251
  /**
247
252
  * Mark cached route data stale and refresh the active view. With `paths`, target exactly those
248
253
  * (e.g. the routes a mutation changed); without, invalidate the whole cache. The active route
249
- * refreshes immediately — refetched + republished — whenever it's in scope (an explicit list that
254
+ * refreshes immediately - refetched + republished - whenever it's in scope (an explicit list that
250
255
  * includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
251
256
  * fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
252
257
  * The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
253
258
  */
254
259
  invalidate: (paths?: readonly string[]) => Promise<void>
255
260
  /**
256
- * Warm a path's chunk + loader data into a bounded one-shot cache without publishing state —
261
+ * Warm a path's chunk + loader data into a bounded one-shot cache without publishing state -
257
262
  * a later `navigate` to it transitions with no network round-trip. Best-effort: failures and
258
263
  * unmatched paths are no-ops. Wired to link hover/focus by `installHistory`.
259
264
  */
260
265
  prefetch: (path: string) => Promise<void>
261
266
  /**
262
- * Get (lazily creating) the stable {@link Fetcher} for `key` — an independent, concurrent
267
+ * Get (lazily creating) the stable {@link Fetcher} for `key` - an independent, concurrent
263
268
  * load/submit state machine for row-level mutations or side-channel loads that must not disturb
264
269
  * the active view. The same `key` always returns the same fetcher (so a binding can subscribe to a
265
270
  * stable store). Keys are app-chosen and typically stable (e.g. a row id).
266
271
  */
267
272
  fetcher: (key: string) => Fetcher
268
- /** All live fetchers — for a global busy view (e.g. a `useFetchers` binding). */
273
+ /** All live fetchers - for a global busy view (e.g. a `useFetchers` binding). */
269
274
  fetchers: () => readonly Fetcher[]
270
- /** Subscribe to any-fetcher-changed (a transition on any fetcher, or a new one created) — backs a
275
+ /** Subscribe to any-fetcher-changed (a transition on any fetcher, or a new one created) - backs a
271
276
  * `useFetchers` binding; returns an unsubscribe fn. */
272
277
  subscribeFetchers: (listener: () => void) => () => void
273
278
  /** Match a path against the manifest patterns (exposed for history/link wiring). */
@@ -285,6 +290,12 @@ export interface ClientRouterOptions {
285
290
  /** Terminal status → client route id. Generated entries populate this from `_404` and
286
291
  * `_<status>` files so soft navigation renders the same boundary as a hard request. */
287
292
  readonly statusRoutes?: Readonly<Record<number, string>>
293
+ /** routeId → the route's client-only search keys (its `searchClientKeys` export). When a soft
294
+ * navigation stays on the same route + pathname and changes ONLY these keys, `navigate` publishes the
295
+ * new URL WITHOUT re-running the loader (re-render, not revalidate). Populated lazily by the generated
296
+ * entry's `loadModule` (so a route's keys are present once it has been visited, which is exactly when a
297
+ * same-route nav can consult them). Omit ⇒ every search change revalidates (the safe default). */
298
+ readonly searchClientKeys?: Readonly<Record<string, readonly string[]>>
288
299
  }
289
300
 
290
301
  /** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
@@ -292,13 +303,20 @@ export interface MountRouterOptions {
292
303
  readonly router: ClientRouter
293
304
  /** routeId → layout chain (outermost layout → page); built by `generateClientEntry`. */
294
305
  readonly routes: Record<string, readonly unknown[]>
295
- /** Hydration container (opaque — the adapter casts it to its DOM element type). */
306
+ /** routeId → the route's search-schema CHAIN (its layout chain's `searchSchema` exports, outermost
307
+ * first, then the page's; each entry `undefined` when that module declares none); built by
308
+ * `generateClientEntry`. The mount derives each route's typed `search` from this chain plus the URL via
309
+ * `searchOfChain` (layout keys merged with page keys, page-wins), matching the server's
310
+ * `ctx.search`/`RenderProps.search`. Omitted by callers with no typed search (tests, a hand-built mount)
311
+ * ⇒ every route sees the raw parsed query. */
312
+ readonly searchSchemas?: Readonly<Record<string, readonly (StandardSchemaV1 | undefined)[]>>
313
+ /** Hydration container (opaque - the adapter casts it to its DOM element type). */
296
314
  readonly container: unknown
297
315
  }
298
316
 
299
317
  /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
300
318
  * `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
301
- * navigation fetches and action submits — both transports are identical. */
319
+ * navigation fetches and action submits - both transports are identical. */
302
320
  const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown> =>
303
321
  (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
304
322
  ? parseNdjsonData(res.body, signal)
@@ -310,7 +328,7 @@ const dataUrlFor = (pathname: string): string =>
310
328
  pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
311
329
 
312
330
  const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
313
- // SSG fast path: if this path was prerendered, its loader data is a static file — fetch that (no
331
+ // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
314
332
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
315
333
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
316
334
  const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
@@ -340,9 +358,23 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
340
358
  * navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
341
359
  * A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
342
360
  */
361
+ // Split a `pathname + search` into its two parts. `rawSearchOf` keeps the leading `?` (what
362
+ // `parseSearch` accepts); an empty query yields `""`.
363
+ const pathnameOf = (path: string): string => {
364
+ const q = path.indexOf("?")
365
+ return q === -1 ? path : path.slice(0, q)
366
+ }
367
+ const rawSearchOf = (path: string): string => {
368
+ const q = path.indexOf("?")
369
+ return q === -1 ? "" : path.slice(q)
370
+ }
371
+
343
372
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
344
373
  const match = createMatcher(options.patterns)
345
374
  const fetchData = options.fetchData ?? defaultFetchData
375
+ // routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
376
+ // to it as routes load), so a same-route nav always sees the current route's keys.
377
+ const searchClientKeys = options.searchClientKeys ?? {}
346
378
  /**
347
379
  * Fetch a route's data and normalise it.
348
380
  *
@@ -410,9 +442,9 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
410
442
  const inflight = new Set<string>()
411
443
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
412
444
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
413
- // increments). Bounded — evict the oldest-inserted past the cap (route data is small; this just
445
+ // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
414
446
  // caps memory). `status` is the staleness ledger: `invalidate` flips entries to `stale`; readers
415
- // refetch stale ones. Client-only — never serialized/hydrated.
447
+ // refetch stale ones. Client-only - never serialized/hydrated.
416
448
  const MAX_CACHE = 50
417
449
  const cache = new Map<
418
450
  string,
@@ -436,7 +468,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
436
468
  }
437
469
  }
438
470
  // Parse the `X-Nifra-Revalidate` response header into validated paths. The header is response data
439
- // (a trust boundary), so each path must match a real manifest pattern — unknown/garbage is dropped
471
+ // (a trust boundary), so each path must match a real manifest pattern - unknown/garbage is dropped
440
472
  // and never triggers a fetch.
441
473
  const parseRevalidate = (header: string | null): string[] =>
442
474
  header === null
@@ -464,7 +496,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
464
496
  { routeId: state.routeId, params: state.params },
465
497
  ac.signal,
466
498
  )
467
- if (mine !== token) return // superseded — drop the stale result
499
+ if (mine !== token) return // superseded - drop the stale result
468
500
  cachePut(state.path, loaded)
469
501
  state = { ...state, data: loaded.data, layoutData: loaded.layoutData, pending: false }
470
502
  emit()
@@ -498,7 +530,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
498
530
 
499
531
  // Best-effort refresh of every MOUNTED reader of `paths`: the active route (unless `skipActive`,
500
532
  // when the caller already refetched it) + any fetcher that loaded one of these paths. Errors are
501
- // swallowed — a failed targeted refresh must not fail the mutation that triggered it.
533
+ // swallowed - a failed targeted refresh must not fail the mutation that triggered it.
502
534
  const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
503
535
  const jobs: Promise<void>[] = []
504
536
  if (!skipActive && paths.includes(state.path)) jobs.push(refetchActive().catch(() => {}))
@@ -531,7 +563,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
531
563
  loadRouteData(path, matched, ac.signal),
532
564
  ])
533
565
  if (mine !== fToken) return // a newer load/submit on THIS fetcher superseded us
534
- // Record the loaded path only on SUCCESS — a thrown or superseded load must not
566
+ // Record the loaded path only on SUCCESS - a thrown or superseded load must not
535
567
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
536
568
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
537
569
  loadedPath = path
@@ -557,7 +589,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
557
589
  load: runLoad,
558
590
  submit: async (action, body) => {
559
591
  const mine = ++fToken
560
- // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) — like
592
+ // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) - like
561
593
  // `runLoad` does for a superseding load. The mutation POST is left to complete; the
562
594
  // signal cancels the follow-up data drain if a newer op supersedes this one.
563
595
  fAbort?.abort()
@@ -581,7 +613,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
581
613
  // Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
582
614
  fState = { pending: false, data: fState.data, actionData }
583
615
  fEmit()
584
- // A fetcher has no loader of its own — its mutation's freshness flows through the cache:
616
+ // A fetcher has no loader of its own - its mutation's freshness flows through the cache:
585
617
  // mark the changed routes stale + refresh every mounted reader (active route + fetchers).
586
618
  if (changed.length > 0) {
587
619
  markStale(changed)
@@ -618,6 +650,27 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
618
650
  navigate: async (path) => {
619
651
  const matched = match(path)
620
652
  if (matched === null) return
653
+ // Client-only search fast path: same route + same pathname, and every changed search key is
654
+ // declared client-only. Publish the new URL (so `useSearch`/`useLocation` update) WITHOUT a data
655
+ // fetch - the loader's inputs did not change. Any loader-affecting key change falls through to the
656
+ // normal match → fetch → publish below, so data is never stale. Abort an in-flight fetch first: a
657
+ // rapid `?tab` after a real nav must not let the older fetch overwrite this synchronous update.
658
+ if (
659
+ matched.routeId === state.routeId &&
660
+ pathnameOf(path) === pathnameOf(state.path) &&
661
+ isClientOnlySearchChange(
662
+ rawSearchOf(state.path),
663
+ rawSearchOf(path),
664
+ searchClientKeys[state.routeId] ?? [],
665
+ )
666
+ ) {
667
+ ++token // supersede any in-flight navigation so its late result is dropped
668
+ navAbort?.abort()
669
+ cachePut(path, { data: state.data, layoutData: state.layoutData })
670
+ state = { ...state, path, pending: false, pendingPath: undefined }
671
+ emit()
672
+ return
673
+ }
621
674
  const mine = ++token
622
675
  navAbort?.abort() // abandon any in-flight navigation's stream
623
676
  const ac = new AbortController()
@@ -625,7 +678,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
625
678
  state = { ...state, pending: true, pendingPath: path }
626
679
  emit()
627
680
  try {
628
- // Use prefetched data when present (one-shot — drop it); else fetch. The chunk is loaded
681
+ // Use prefetched data when present (one-shot - drop it); else fetch. The chunk is loaded
629
682
  // either way (cached + instant after a prefetch), so pending covers both.
630
683
  const hit = prefetched.has(path)
631
684
  const dataPromise = hit
@@ -641,7 +694,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
641
694
  if (hit) prefetched.delete(path)
642
695
  const [, loaded] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
643
696
  if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
644
- if (mine !== token) return // a newer navigation superseded this one — drop the stale result
697
+ if (mine !== token) return // a newer navigation superseded this one - drop the stale result
645
698
  cachePut(path, loaded) // keep the keyed cache coherent with what we publish
646
699
  state = {
647
700
  routeId: loaded.terminalRouteId ?? matched.routeId,
@@ -654,7 +707,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
654
707
  }
655
708
  emit()
656
709
  } catch (err) {
657
- // Clear our pending flag + target (only if still current) and rethrow — the caller decides how
710
+ // Clear our pending flag + target (only if still current) and rethrow - the caller decides how
658
711
  // to recover (the history layer falls back to a full-page navigation).
659
712
  if (mine === token) {
660
713
  state = { ...state, pending: false, pendingPath: undefined }
@@ -666,7 +719,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
666
719
  submit: async (action, body, opts) => {
667
720
  const mine = ++token
668
721
  // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
669
- // fetch + their NDJSON drains) — not the mutation POST itself, which should complete.
722
+ // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
670
723
  // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
671
724
  navAbort?.abort()
672
725
  const ac = new AbortController()
@@ -684,7 +737,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
684
737
  if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
685
738
  const redirectTo = res.headers.get(REDIRECT_HEADER)
686
739
  if (redirectTo !== null) {
687
- // The action redirected (Post/Redirect/Get) — treat it as a client navigation.
740
+ // The action redirected (Post/Redirect/Get) - treat it as a client navigation.
688
741
  const target = match(redirectTo)
689
742
  if (target === null)
690
743
  throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
@@ -714,14 +767,14 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
714
767
  emit()
715
768
  return
716
769
  }
717
- // The action's data — streamed NDJSON if it `defer()`'d slow parts (markers settle as lines
770
+ // The action's data - streamed NDJSON if it `defer()`'d slow parts (markers settle as lines
718
771
  // arrive, for `<Await actionData>`), else one JSON. Abortable so a superseding nav cancels the
719
772
  // drain.
720
773
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
721
774
  // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
722
775
  // validated against the manifest.
723
776
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
724
- // Revalidate the active route's loader so the mutation is reflected — unless the caller opted
777
+ // Revalidate the active route's loader so the mutation is reflected - unless the caller opted
725
778
  // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
726
779
  // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
727
780
  const skipActive = opts?.revalidate === false && !changed.includes(state.path)
@@ -737,7 +790,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
737
790
  // Mark the OTHER changed routes stale so the next access refetches.
738
791
  markStale(changed.filter((p) => p !== state.path))
739
792
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
740
- // window is over — the real data now drives the view).
793
+ // window is over - the real data now drives the view).
741
794
  state = {
742
795
  routeId: state.routeId,
743
796
  params: state.params,
@@ -749,7 +802,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
749
802
  }
750
803
  emit()
751
804
  // Refresh any mounted fetcher showing one of the changed routes (the active route was just
752
- // revalidated inline above, so skip it here). Best-effort — never rejects the submit.
805
+ // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
753
806
  await refreshMounted(changed, true)
754
807
  } catch (err) {
755
808
  if (mine === token) {
@@ -769,11 +822,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
769
822
  }
770
823
  },
771
824
  invalidate: async (paths) => {
772
- // Mark targeted cache entries stale (all entries when no `paths`) — unmounted ones refetch
825
+ // Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
773
826
  // lazily on next access.
774
827
  const targets = paths ?? [...cache.keys()]
775
828
  markStale(targets)
776
- // Refresh mounted readers now: the active route (when in scope — an explicit list including it,
829
+ // Refresh mounted readers now: the active route (when in scope - an explicit list including it,
777
830
  // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
778
831
  // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
779
832
  const jobs: Promise<void>[] = []
@@ -787,7 +840,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
787
840
  if (matched === null) return
788
841
  inflight.add(path)
789
842
  try {
790
- // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from —
843
+ // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
791
844
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
792
845
  const [, loaded] = await Promise.all([
793
846
  loadModule?.(matched.routeId),
@@ -809,7 +862,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
809
862
  if (existing !== undefined) return existing.fetcher
810
863
  const entry = createFetcher()
811
864
  fetchers.set(key, entry)
812
- emitFetchers() // a new fetcher appeared — wake any `useFetchers` subscriber
865
+ emitFetchers() // a new fetcher appeared - wake any `useFetchers` subscriber
813
866
  return entry.fetcher
814
867
  },
815
868
  fetchers: () => fetchersArr,