@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.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +2 -2
- package/dist/dev-port.js +1 -1
- package/dist/dev.d.ts +3 -3
- package/dist/dev.js +9 -9
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +57 -28
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +15 -13
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +31 -21
- package/dist/vite.js.map +1 -1
- package/package.json +33 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +2 -2
- package/src/dev.ts +12 -12
- package/src/draft.ts +15 -15
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +2 -2
- package/src/index.ts +460 -181
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +2 -2
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +91 -41
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +41 -29
package/src/router.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agnostic client-side router core
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
862
|
+
emitFetchers() // a new fetcher appeared - wake any `useFetchers` subscriber
|
|
813
863
|
return entry.fetcher
|
|
814
864
|
},
|
|
815
865
|
fetchers: () => fetchersArr,
|