@nifrajs/web 2.2.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 (163) 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-error.d.ts +2 -2
  23. package/dist/dev-error.js +3 -3
  24. package/dist/dev-port.d.ts +2 -2
  25. package/dist/dev-port.js +1 -1
  26. package/dist/dev.d.ts +3 -3
  27. package/dist/dev.js +9 -9
  28. package/dist/draft.d.ts +12 -12
  29. package/dist/draft.js +11 -11
  30. package/dist/fn-state.d.ts +56 -0
  31. package/dist/fn-state.d.ts.map +1 -0
  32. package/dist/fn-state.js +77 -0
  33. package/dist/fn-state.js.map +1 -0
  34. package/dist/fn.d.ts +111 -0
  35. package/dist/fn.d.ts.map +1 -0
  36. package/dist/fn.js +125 -0
  37. package/dist/fn.js.map +1 -0
  38. package/dist/fonts-google.d.ts +9 -9
  39. package/dist/fonts-google.js +10 -10
  40. package/dist/fonts.d.ts +7 -7
  41. package/dist/fonts.js +1 -1
  42. package/dist/forms.d.ts +8 -8
  43. package/dist/fs.d.ts +1 -1
  44. package/dist/fs.js +1 -1
  45. package/dist/index.d.ts +151 -70
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +317 -139
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/head-attributes.d.ts +15 -0
  50. package/dist/internal/head-attributes.d.ts.map +1 -0
  51. package/dist/internal/head-attributes.js +99 -0
  52. package/dist/internal/head-attributes.js.map +1 -0
  53. package/dist/internal/script-types.d.ts +14 -0
  54. package/dist/internal/script-types.d.ts.map +1 -0
  55. package/dist/internal/script-types.js +17 -0
  56. package/dist/internal/script-types.js.map +1 -0
  57. package/dist/internal/server-boundary.d.ts +60 -0
  58. package/dist/internal/server-boundary.d.ts.map +1 -0
  59. package/dist/internal/server-boundary.js +174 -0
  60. package/dist/internal/server-boundary.js.map +1 -0
  61. package/dist/internal/timing-safe-equal.d.ts +1 -1
  62. package/dist/internal/timing-safe-equal.js +1 -1
  63. package/dist/internal/vite-import.d.ts +63 -0
  64. package/dist/internal/vite-import.d.ts.map +1 -0
  65. package/dist/internal/vite-import.js +94 -0
  66. package/dist/internal/vite-import.js.map +1 -0
  67. package/dist/islands.d.ts +5 -5
  68. package/dist/islands.js +5 -5
  69. package/dist/isr.d.ts +15 -15
  70. package/dist/isr.js +7 -7
  71. package/dist/manifest.d.ts +73 -35
  72. package/dist/manifest.d.ts.map +1 -1
  73. package/dist/manifest.js +10 -16
  74. package/dist/manifest.js.map +1 -1
  75. package/dist/module-graph.d.ts +5 -5
  76. package/dist/module-graph.js +4 -4
  77. package/dist/navigation.d.ts +120 -5
  78. package/dist/navigation.d.ts.map +1 -1
  79. package/dist/navigation.js +37 -5
  80. package/dist/navigation.js.map +1 -1
  81. package/dist/plugins/css-modules.d.ts +3 -3
  82. package/dist/plugins/css-modules.js +0 -0
  83. package/dist/plugins/kit.d.ts +8 -8
  84. package/dist/plugins/kit.js +9 -9
  85. package/dist/plugins/scss.js +3 -3
  86. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  87. package/dist/plugins/vite-leak-guard.js +1 -1
  88. package/dist/plugins/vite-server-fn.d.ts +23 -0
  89. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  90. package/dist/plugins/vite-server-fn.js +51 -0
  91. package/dist/plugins/vite-server-fn.js.map +1 -0
  92. package/dist/plugins/vite-server-only.d.ts +49 -0
  93. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  94. package/dist/plugins/vite-server-only.js +57 -0
  95. package/dist/plugins/vite-server-only.js.map +1 -0
  96. package/dist/prerender.d.ts +5 -5
  97. package/dist/prerender.js +7 -7
  98. package/dist/query.d.ts +10 -10
  99. package/dist/query.js +15 -15
  100. package/dist/route-manifest.d.ts +1 -1
  101. package/dist/route-manifest.js +2 -2
  102. package/dist/router.d.ts +30 -21
  103. package/dist/router.d.ts.map +1 -1
  104. package/dist/router.js +57 -28
  105. package/dist/router.js.map +1 -1
  106. package/dist/search.d.ts +94 -0
  107. package/dist/search.d.ts.map +1 -0
  108. package/dist/search.js +232 -0
  109. package/dist/search.js.map +1 -0
  110. package/dist/server-only.d.ts +6 -6
  111. package/dist/server-only.js +7 -7
  112. package/dist/service-worker.d.ts +56 -0
  113. package/dist/service-worker.d.ts.map +1 -0
  114. package/dist/service-worker.js +127 -0
  115. package/dist/service-worker.js.map +1 -0
  116. package/dist/vite.d.ts +15 -13
  117. package/dist/vite.d.ts.map +1 -1
  118. package/dist/vite.js +31 -21
  119. package/dist/vite.js.map +1 -1
  120. package/package.json +33 -4
  121. package/server-manifest.d.ts +31 -0
  122. package/src/build-vite.ts +24 -77
  123. package/src/build.ts +137 -105
  124. package/src/bun-dev-entry.ts +1 -1
  125. package/src/chunk-names.ts +1 -1
  126. package/src/client.ts +215 -32
  127. package/src/conformance.ts +3 -3
  128. package/src/deferred.ts +17 -17
  129. package/src/dev-error.ts +3 -3
  130. package/src/dev-port.ts +2 -2
  131. package/src/dev.ts +12 -12
  132. package/src/draft.ts +15 -15
  133. package/src/fn-state.ts +107 -0
  134. package/src/fn.ts +199 -0
  135. package/src/fonts-google.ts +13 -13
  136. package/src/fonts.ts +7 -7
  137. package/src/forms.ts +8 -8
  138. package/src/fs.ts +2 -2
  139. package/src/index.ts +460 -181
  140. package/src/internal/head-attributes.ts +109 -0
  141. package/src/internal/script-types.ts +18 -0
  142. package/src/internal/server-boundary.ts +187 -0
  143. package/src/internal/timing-safe-equal.ts +1 -1
  144. package/src/internal/vite-import.ts +92 -0
  145. package/src/islands.ts +7 -7
  146. package/src/isr.ts +17 -17
  147. package/src/manifest.ts +83 -42
  148. package/src/module-graph.ts +6 -6
  149. package/src/navigation.ts +151 -5
  150. package/src/plugins/css-modules.ts +0 -0
  151. package/src/plugins/kit.ts +11 -11
  152. package/src/plugins/scss.ts +3 -3
  153. package/src/plugins/vite-leak-guard.ts +2 -2
  154. package/src/plugins/vite-server-fn.ts +67 -0
  155. package/src/plugins/vite-server-only.ts +75 -0
  156. package/src/prerender.ts +10 -10
  157. package/src/query.ts +22 -22
  158. package/src/route-manifest.ts +3 -3
  159. package/src/router.ts +91 -41
  160. package/src/search.ts +297 -0
  161. package/src/server-only.ts +7 -7
  162. package/src/service-worker.ts +165 -0
  163. package/src/vite.ts +41 -29
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,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
@@ -158,7 +160,7 @@ export function createMatcher(
158
160
  .map((pattern) => ({ routeId: pattern.routeId, pattern: compileRoutePattern(pattern.pattern) }))
159
161
  .sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
160
162
  return (path) => {
161
- // 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.
162
164
  const q = path.indexOf("?")
163
165
  const clean = q === -1 ? path : path.slice(0, q)
164
166
  for (const c of compiled) {
@@ -192,7 +194,7 @@ export interface SubmitOptions {
192
194
  readonly revalidate?: boolean
193
195
  }
194
196
 
195
- /** 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
196
198
  * load/submit; `data` is its last `load()` result; `actionData` its last `submit()` result;
197
199
  * `submission` the in-flight submit (for optimistic UI). Client-only (never SSR'd). */
198
200
  export interface FetcherState {
@@ -204,7 +206,7 @@ export interface FetcherState {
204
206
 
205
207
  /**
206
208
  * 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
209
+ * with the main router and with other fetchers - each is single-flight against *itself* (its own
208
210
  * monotonic token), so N row-level mutations / side-channel loads can be in flight at once without
209
211
  * disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
210
212
  */
@@ -218,7 +220,7 @@ export interface Fetcher {
218
220
  load: (path: string) => Promise<void>
219
221
  /** Submit an action into this fetcher's own state; honors `X-Nifra-Revalidate` by refreshing the
220
222
  * 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.) */
223
+ * (No `revalidate` opt-out - a fetcher has no active loader of its own to skip.) */
222
224
  submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
223
225
  }
224
226
 
@@ -234,7 +236,7 @@ export interface ClientRouter {
234
236
  * Submit an action (POST `body` to `action` in data mode): a redirect becomes a client
235
237
  * navigation; otherwise the data return is published as `actionData` and the active route's
236
238
  * 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
239
+ * revalidation - keep the current `data` and just publish the action's `actionData` (useful when
238
240
  * the action already returned everything that changed, saving the extra round-trip). A redirect
239
241
  * always loads its target regardless. Rejects on failure (caller falls back).
240
242
  */
@@ -246,28 +248,28 @@ export interface ClientRouter {
246
248
  /**
247
249
  * Mark cached route data stale and refresh the active view. With `paths`, target exactly those
248
250
  * (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
251
+ * refreshes immediately - refetched + republished - whenever it's in scope (an explicit list that
250
252
  * includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
251
253
  * fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
252
254
  * The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
253
255
  */
254
256
  invalidate: (paths?: readonly string[]) => Promise<void>
255
257
  /**
256
- * 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 -
257
259
  * a later `navigate` to it transitions with no network round-trip. Best-effort: failures and
258
260
  * unmatched paths are no-ops. Wired to link hover/focus by `installHistory`.
259
261
  */
260
262
  prefetch: (path: string) => Promise<void>
261
263
  /**
262
- * 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
263
265
  * load/submit state machine for row-level mutations or side-channel loads that must not disturb
264
266
  * the active view. The same `key` always returns the same fetcher (so a binding can subscribe to a
265
267
  * stable store). Keys are app-chosen and typically stable (e.g. a row id).
266
268
  */
267
269
  fetcher: (key: string) => Fetcher
268
- /** 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). */
269
271
  fetchers: () => readonly Fetcher[]
270
- /** 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
271
273
  * `useFetchers` binding; returns an unsubscribe fn. */
272
274
  subscribeFetchers: (listener: () => void) => () => void
273
275
  /** Match a path against the manifest patterns (exposed for history/link wiring). */
@@ -285,6 +287,12 @@ export interface ClientRouterOptions {
285
287
  /** Terminal status → client route id. Generated entries populate this from `_404` and
286
288
  * `_<status>` files so soft navigation renders the same boundary as a hard request. */
287
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[]>>
288
296
  }
289
297
 
290
298
  /** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
@@ -292,13 +300,20 @@ export interface MountRouterOptions {
292
300
  readonly router: ClientRouter
293
301
  /** routeId → layout chain (outermost layout → page); built by `generateClientEntry`. */
294
302
  readonly routes: Record<string, readonly unknown[]>
295
- /** 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). */
296
311
  readonly container: unknown
297
312
  }
298
313
 
299
314
  /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
300
315
  * `<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. */
316
+ * navigation fetches and action submits - both transports are identical. */
302
317
  const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown> =>
303
318
  (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
304
319
  ? parseNdjsonData(res.body, signal)
@@ -310,7 +325,7 @@ const dataUrlFor = (pathname: string): string =>
310
325
  pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
311
326
 
312
327
  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
328
+ // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
314
329
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
315
330
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
316
331
  const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
@@ -340,9 +355,23 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
340
355
  * navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
341
356
  * A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
342
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
+
343
369
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
344
370
  const match = createMatcher(options.patterns)
345
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 ?? {}
346
375
  /**
347
376
  * Fetch a route's data and normalise it.
348
377
  *
@@ -410,9 +439,9 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
410
439
  const inflight = new Set<string>()
411
440
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
412
441
  // (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
442
+ // increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
414
443
  // caps memory). `status` is the staleness ledger: `invalidate` flips entries to `stale`; readers
415
- // refetch stale ones. Client-only — never serialized/hydrated.
444
+ // refetch stale ones. Client-only - never serialized/hydrated.
416
445
  const MAX_CACHE = 50
417
446
  const cache = new Map<
418
447
  string,
@@ -436,7 +465,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
436
465
  }
437
466
  }
438
467
  // 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
468
+ // (a trust boundary), so each path must match a real manifest pattern - unknown/garbage is dropped
440
469
  // and never triggers a fetch.
441
470
  const parseRevalidate = (header: string | null): string[] =>
442
471
  header === null
@@ -464,7 +493,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
464
493
  { routeId: state.routeId, params: state.params },
465
494
  ac.signal,
466
495
  )
467
- if (mine !== token) return // superseded — drop the stale result
496
+ if (mine !== token) return // superseded - drop the stale result
468
497
  cachePut(state.path, loaded)
469
498
  state = { ...state, data: loaded.data, layoutData: loaded.layoutData, pending: false }
470
499
  emit()
@@ -498,7 +527,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
498
527
 
499
528
  // Best-effort refresh of every MOUNTED reader of `paths`: the active route (unless `skipActive`,
500
529
  // 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.
530
+ // swallowed - a failed targeted refresh must not fail the mutation that triggered it.
502
531
  const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
503
532
  const jobs: Promise<void>[] = []
504
533
  if (!skipActive && paths.includes(state.path)) jobs.push(refetchActive().catch(() => {}))
@@ -531,7 +560,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
531
560
  loadRouteData(path, matched, ac.signal),
532
561
  ])
533
562
  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
563
+ // Record the loaded path only on SUCCESS - a thrown or superseded load must not
535
564
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
536
565
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
537
566
  loadedPath = path
@@ -557,7 +586,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
557
586
  load: runLoad,
558
587
  submit: async (action, body) => {
559
588
  const mine = ++fToken
560
- // 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
561
590
  // `runLoad` does for a superseding load. The mutation POST is left to complete; the
562
591
  // signal cancels the follow-up data drain if a newer op supersedes this one.
563
592
  fAbort?.abort()
@@ -581,7 +610,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
581
610
  // Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
582
611
  fState = { pending: false, data: fState.data, actionData }
583
612
  fEmit()
584
- // 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:
585
614
  // mark the changed routes stale + refresh every mounted reader (active route + fetchers).
586
615
  if (changed.length > 0) {
587
616
  markStale(changed)
@@ -618,6 +647,27 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
618
647
  navigate: async (path) => {
619
648
  const matched = match(path)
620
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
+ }
621
671
  const mine = ++token
622
672
  navAbort?.abort() // abandon any in-flight navigation's stream
623
673
  const ac = new AbortController()
@@ -625,7 +675,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
625
675
  state = { ...state, pending: true, pendingPath: path }
626
676
  emit()
627
677
  try {
628
- // 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
629
679
  // either way (cached + instant after a prefetch), so pending covers both.
630
680
  const hit = prefetched.has(path)
631
681
  const dataPromise = hit
@@ -641,7 +691,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
641
691
  if (hit) prefetched.delete(path)
642
692
  const [, loaded] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
643
693
  if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
644
- if (mine !== token) return // a newer navigation superseded this one — drop the stale result
694
+ if (mine !== token) return // a newer navigation superseded this one - drop the stale result
645
695
  cachePut(path, loaded) // keep the keyed cache coherent with what we publish
646
696
  state = {
647
697
  routeId: loaded.terminalRouteId ?? matched.routeId,
@@ -654,7 +704,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
654
704
  }
655
705
  emit()
656
706
  } catch (err) {
657
- // 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
658
708
  // to recover (the history layer falls back to a full-page navigation).
659
709
  if (mine === token) {
660
710
  state = { ...state, pending: false, pendingPath: undefined }
@@ -666,7 +716,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
666
716
  submit: async (action, body, opts) => {
667
717
  const mine = ++token
668
718
  // 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.
719
+ // fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
670
720
  // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
671
721
  navAbort?.abort()
672
722
  const ac = new AbortController()
@@ -684,7 +734,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
684
734
  if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
685
735
  const redirectTo = res.headers.get(REDIRECT_HEADER)
686
736
  if (redirectTo !== null) {
687
- // 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.
688
738
  const target = match(redirectTo)
689
739
  if (target === null)
690
740
  throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
@@ -714,14 +764,14 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
714
764
  emit()
715
765
  return
716
766
  }
717
- // 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
718
768
  // arrive, for `<Await actionData>`), else one JSON. Abortable so a superseding nav cancels the
719
769
  // drain.
720
770
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
721
771
  // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
722
772
  // validated against the manifest.
723
773
  const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
724
- // 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
725
775
  // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
726
776
  // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
727
777
  const skipActive = opts?.revalidate === false && !changed.includes(state.path)
@@ -737,7 +787,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
737
787
  // Mark the OTHER changed routes stale so the next access refetches.
738
788
  markStale(changed.filter((p) => p !== state.path))
739
789
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
740
- // window is over — the real data now drives the view).
790
+ // window is over - the real data now drives the view).
741
791
  state = {
742
792
  routeId: state.routeId,
743
793
  params: state.params,
@@ -749,7 +799,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
749
799
  }
750
800
  emit()
751
801
  // 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.
802
+ // revalidated inline above, so skip it here). Best-effort - never rejects the submit.
753
803
  await refreshMounted(changed, true)
754
804
  } catch (err) {
755
805
  if (mine === token) {
@@ -769,11 +819,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
769
819
  }
770
820
  },
771
821
  invalidate: async (paths) => {
772
- // 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
773
823
  // lazily on next access.
774
824
  const targets = paths ?? [...cache.keys()]
775
825
  markStale(targets)
776
- // 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,
777
827
  // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
778
828
  // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
779
829
  const jobs: Promise<void>[] = []
@@ -787,7 +837,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
787
837
  if (matched === null) return
788
838
  inflight.add(path)
789
839
  try {
790
- // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from —
840
+ // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
791
841
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
792
842
  const [, loaded] = await Promise.all([
793
843
  loadModule?.(matched.routeId),
@@ -809,7 +859,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
809
859
  if (existing !== undefined) return existing.fetcher
810
860
  const entry = createFetcher()
811
861
  fetchers.set(key, entry)
812
- emitFetchers() // a new fetcher appeared — wake any `useFetchers` subscriber
862
+ emitFetchers() // a new fetcher appeared - wake any `useFetchers` subscriber
813
863
  return entry.fetcher
814
864
  },
815
865
  fetchers: () => fetchersArr,