@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.
- 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-diagnostics.d.ts +20 -0
- package/dist/dev-diagnostics.d.ts.map +1 -0
- package/dist/dev-diagnostics.js +34 -0
- package/dist/dev-diagnostics.js.map +1 -0
- package/dist/dev-error.d.ts +10 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +51 -37
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-port.d.ts +3 -3
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +10 -4
- package/dist/dev-port.js.map +1 -1
- package/dist/dev.d.ts +4 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +28 -12
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +93 -0
- package/dist/diagnostic.d.ts.map +1 -0
- package/dist/diagnostic.js +205 -0
- package/dist/diagnostic.js.map +1 -0
- 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 +62 -32
- 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 +16 -14
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -24
- package/dist/vite.js.map +1 -1
- package/package.json +38 -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-diagnostics.ts +47 -0
- package/src/dev-error.ts +64 -33
- package/src/dev-port.ts +17 -5
- package/src/dev.ts +36 -20
- package/src/diagnostic.ts +284 -0
- 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 +98 -45
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +61 -32
package/src/router.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
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.
|
|
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
|
|
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
|
|
@@ -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 =
|
|
158
|
-
.map((pattern) => ({
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
865
|
+
emitFetchers() // a new fetcher appeared - wake any `useFetchers` subscriber
|
|
813
866
|
return entry.fetcher
|
|
814
867
|
},
|
|
815
868
|
fetchers: () => fetchersArr,
|