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