@nifrajs/web 2.14.1 → 3.2.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/dist/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-plan.d.ts +228 -0
- package/dist/build-plan.d.ts.map +1 -0
- package/dist/build-plan.js +514 -0
- package/dist/build-plan.js.map +1 -0
- package/dist/build-vite.d.ts +2 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +2 -197
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +92 -515
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts.map +1 -1
- package/dist/chunk-names.js +3 -2
- package/dist/chunk-names.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +63 -28
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +9 -2
- package/dist/diagnostic.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +3 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +2 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +16 -589
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1824
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +68 -0
- package/dist/internal/codegen.d.ts.map +1 -0
- package/dist/internal/codegen.js +317 -0
- package/dist/internal/codegen.js.map +1 -0
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/head-merge.d.ts +2 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +17 -1
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +104 -8
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +472 -56
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/internal/render-document.d.ts +372 -0
- package/dist/internal/render-document.d.ts.map +1 -0
- package/dist/internal/render-document.js +744 -0
- package/dist/internal/render-document.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +48 -0
- package/dist/internal/runtime-contract.d.ts.map +1 -0
- package/dist/internal/runtime-contract.js +27 -0
- package/dist/internal/runtime-contract.js.map +1 -0
- package/dist/internal/web-app.d.ts +171 -0
- package/dist/internal/web-app.d.ts.map +1 -0
- package/dist/internal/web-app.js +844 -0
- package/dist/internal/web-app.js.map +1 -0
- package/dist/islands.d.ts +50 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +96 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +22 -9
- package/dist/module-graph.js.map +1 -1
- package/dist/nano.d.ts +125 -0
- package/dist/nano.d.ts.map +1 -0
- package/dist/nano.js +190 -0
- package/dist/nano.js.map +1 -0
- package/dist/plugins/css-modules.js +2 -2
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +12 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +62 -12
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/postcss.js +2 -2
- package/dist/plugins/postcss.js.map +1 -1
- package/dist/plugins/scss.js +2 -2
- package/dist/plugins/scss.js.map +1 -1
- package/dist/plugins/stylex.d.ts +70 -0
- package/dist/plugins/stylex.d.ts.map +1 -0
- package/dist/plugins/stylex.js +166 -0
- package/dist/plugins/stylex.js.map +1 -0
- package/dist/plugins/svg.js +2 -2
- package/dist/plugins/svg.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +11 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +254 -47
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +34 -16
- package/dist/vite.js.map +1 -1
- package/package.json +49 -2
- package/src/boundary.ts +407 -0
- package/src/build-plan.ts +741 -0
- package/src/build-vite.ts +6 -7
- package/src/build.ts +120 -730
- package/src/chunk-names.ts +3 -2
- package/src/dev-ssr-graph.ts +63 -28
- package/src/diagnostic.ts +14 -2
- package/src/fonts-google.ts +3 -1
- package/src/fs.ts +2 -1
- package/src/index.ts +107 -2553
- package/src/internal/codegen.ts +400 -0
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/head-merge.ts +19 -1
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +603 -71
- package/src/internal/query-runtime.ts +31 -0
- package/src/internal/render-document.ts +1116 -0
- package/src/internal/runtime-contract.ts +54 -0
- package/src/internal/web-app.ts +1114 -0
- package/src/islands.ts +106 -40
- package/src/manifest.ts +50 -3
- package/src/module-graph.ts +23 -9
- package/src/nano.ts +246 -0
- package/src/plugins/css-modules.ts +2 -2
- package/src/plugins/kit.ts +65 -12
- package/src/plugins/postcss.ts +2 -2
- package/src/plugins/scss.ts +2 -2
- package/src/plugins/stylex.ts +298 -0
- package/src/plugins/svg.ts +2 -2
- package/src/render-seam.ts +6 -0
- package/src/router.ts +293 -51
- package/src/vite.ts +41 -15
package/src/router.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import { decodeRouteParams } from "@nifrajs/core/pattern"
|
|
8
8
|
import { Router as CoreRouter } from "@nifrajs/core/router"
|
|
9
9
|
import type { StandardSchemaV1 } from "@nifrajs/core/server"
|
|
10
|
+
import type { BoundaryDescriptor, BoundaryStates } from "./boundary.ts"
|
|
10
11
|
import { parseNdjsonData } from "./deferred.ts"
|
|
12
|
+
import type { ClientActionResult, ClientRequestBody, ClientRouteHooks } from "./manifest.ts"
|
|
11
13
|
import { isClientOnlySearchChange } from "./search.ts"
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -89,6 +91,8 @@ export interface RouteDataEnvelope {
|
|
|
89
91
|
/** Indices whose loader was SKIPPED because the layout's own params did not change. The client
|
|
90
92
|
* keeps its existing value at each of these, so an unchanged layout is neither refetched nor lost. */
|
|
91
93
|
readonly retained?: readonly number[]
|
|
94
|
+
/** Dynamic-boundary states returned alongside loader/layout data. */
|
|
95
|
+
readonly boundaries?: BoundaryStates
|
|
92
96
|
/** Terminal status signalled by a loader. Added by the browser transport after reading
|
|
93
97
|
* `X-Nifra-Status`; old servers and static data files simply omit it. */
|
|
94
98
|
readonly status?: number
|
|
@@ -128,6 +132,8 @@ export interface RouterState {
|
|
|
128
132
|
/** Per-layout loader data, aligned with the matched chain's leading layout prefix. Absent when no
|
|
129
133
|
* layout in that chain has a loader. */
|
|
130
134
|
readonly layoutData?: readonly unknown[] | undefined
|
|
135
|
+
/** Neutral named-boundary states for the active route. */
|
|
136
|
+
readonly boundaries?: BoundaryStates
|
|
131
137
|
/** An action's data return after a client-side submit (cleared on navigation). */
|
|
132
138
|
readonly actionData?: unknown
|
|
133
139
|
/** True while a navigation or submit is in flight (drives loading UI). */
|
|
@@ -209,7 +215,7 @@ export interface FetcherState {
|
|
|
209
215
|
/**
|
|
210
216
|
* An independent load/submit state machine, retrieved by `router.fetcher(key)`. Runs **concurrently**
|
|
211
217
|
* with the main router and with other fetchers - each is single-flight against *itself* (its own
|
|
212
|
-
* monotonic
|
|
218
|
+
* monotonic generation), so N row-level mutations / side-channel loads can be in flight at once without
|
|
213
219
|
* disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
|
|
214
220
|
*/
|
|
215
221
|
export interface Fetcher {
|
|
@@ -247,6 +253,8 @@ export interface ClientRouter {
|
|
|
247
253
|
body: NonNullable<RequestInit["body"]>,
|
|
248
254
|
opts?: SubmitOptions,
|
|
249
255
|
) => Promise<void>
|
|
256
|
+
/** Run the initial route's client loader after the adapter has hydrated the SSR markup. */
|
|
257
|
+
hydrate: () => Promise<void>
|
|
250
258
|
/**
|
|
251
259
|
* Mark cached route data stale and refresh the active view. With `paths`, target exactly those
|
|
252
260
|
* (e.g. the routes a mutation changed); without, invalidate the whole cache. The active route
|
|
@@ -295,6 +303,8 @@ export interface ClientRouterOptions {
|
|
|
295
303
|
* entry's `loadModule` (so a route's keys are present once it has been visited, which is exactly when a
|
|
296
304
|
* same-route nav can consult them). Omit ⇒ every search change revalidates (the safe default). */
|
|
297
305
|
readonly searchClientKeys?: Readonly<Record<string, readonly string[]>>
|
|
306
|
+
/** routeId → client-only loader/action hooks, populated by the generated route entry. */
|
|
307
|
+
readonly routeHooks?: Readonly<Record<string, ClientRouteHooks | undefined>>
|
|
298
308
|
}
|
|
299
309
|
|
|
300
310
|
/** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
|
|
@@ -378,7 +388,7 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
|
|
|
378
388
|
}
|
|
379
389
|
|
|
380
390
|
/**
|
|
381
|
-
* Create the agnostic router store. `navigate` is guarded by a monotonic
|
|
391
|
+
* Create the agnostic router store. `navigate` is guarded by a monotonic generation so that when
|
|
382
392
|
* navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
|
|
383
393
|
* A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
|
|
384
394
|
*/
|
|
@@ -393,12 +403,58 @@ const rawSearchOf = (path: string): string => {
|
|
|
393
403
|
return q === -1 ? "" : path.slice(q)
|
|
394
404
|
}
|
|
395
405
|
|
|
406
|
+
/** Validate the client-action envelope before reading it. Its `body` is deliberately not treated as
|
|
407
|
+
* trusted data: it is an opaque RequestInit body that the server action must parse and authorize. */
|
|
408
|
+
const isClientActionResult = (value: unknown): value is ClientActionResult =>
|
|
409
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
410
|
+
|
|
396
411
|
export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
397
412
|
const match = createMatcher(options.patterns)
|
|
398
413
|
const fetchData = options.fetchData ?? defaultFetchData
|
|
399
414
|
// routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
|
|
400
415
|
// to it as routes load), so a same-route nav always sees the current route's keys.
|
|
401
416
|
const searchClientKeys = options.searchClientKeys ?? {}
|
|
417
|
+
// routeId → client hooks. The generated entry mutates this object only while loading a route module;
|
|
418
|
+
// route navigation reads it after `loadModule` resolves, so an app without hooks pays only a map
|
|
419
|
+
// lookup and keeps the existing route-chunk split.
|
|
420
|
+
const routeHooks = options.routeHooks ?? {}
|
|
421
|
+
// Intercept patterns are compiled lazily from the already-loaded source route descriptor. Invalid
|
|
422
|
+
// client metadata is ignored (the server's register-time boundary validation remains authoritative),
|
|
423
|
+
// so a hand-built client manifest fails closed into an ordinary navigation.
|
|
424
|
+
const interceptMatchers = new Map<string, ReturnType<typeof createMatcher> | null>()
|
|
425
|
+
const interceptionFor = (
|
|
426
|
+
sourceRouteId: string,
|
|
427
|
+
path: string,
|
|
428
|
+
target: RouteMatch,
|
|
429
|
+
): BoundaryDescriptor | undefined => {
|
|
430
|
+
const boundaries = routeHooks[sourceRouteId]?.boundaries ?? []
|
|
431
|
+
const pathname = pathnameOf(path)
|
|
432
|
+
for (const boundary of boundaries) {
|
|
433
|
+
if (typeof boundary.mode === "string") continue
|
|
434
|
+
const pattern = boundary.mode.intercept
|
|
435
|
+
if (
|
|
436
|
+
pattern.length === 0 ||
|
|
437
|
+
pattern.length > 2048 ||
|
|
438
|
+
!pattern.startsWith("/") ||
|
|
439
|
+
pattern.startsWith("//")
|
|
440
|
+
)
|
|
441
|
+
continue
|
|
442
|
+
let matcher = interceptMatchers.get(pattern)
|
|
443
|
+
if (matcher === undefined) {
|
|
444
|
+
try {
|
|
445
|
+
matcher = createMatcher([{ routeId: "__nifra_intercept__", pattern }])
|
|
446
|
+
} catch {
|
|
447
|
+
matcher = null
|
|
448
|
+
}
|
|
449
|
+
interceptMatchers.set(pattern, matcher)
|
|
450
|
+
}
|
|
451
|
+
if (matcher?.(pathname)?.routeId === "__nifra_intercept__") return boundary
|
|
452
|
+
}
|
|
453
|
+
// Keep `target` in the signature so the caller cannot accidentally install an interceptor for an
|
|
454
|
+
// unmatched route; the match is intentionally performed before this helper is called.
|
|
455
|
+
void target
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
402
458
|
/**
|
|
403
459
|
* Fetch a route's data and normalise it.
|
|
404
460
|
*
|
|
@@ -411,6 +467,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
411
467
|
readonly data: unknown
|
|
412
468
|
readonly layoutData?: readonly unknown[] | undefined
|
|
413
469
|
readonly terminalRouteId?: string | undefined
|
|
470
|
+
readonly boundaries?: BoundaryStates
|
|
414
471
|
}
|
|
415
472
|
const loadRouteData = async (
|
|
416
473
|
path: string,
|
|
@@ -436,7 +493,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
436
493
|
return { data: null, terminalRouteId }
|
|
437
494
|
}
|
|
438
495
|
if (retainFrom === undefined || payload.retained === undefined) {
|
|
439
|
-
return {
|
|
496
|
+
return {
|
|
497
|
+
data: payload.data,
|
|
498
|
+
layoutData: payload.layoutData,
|
|
499
|
+
...(payload.boundaries !== undefined ? { boundaries: payload.boundaries } : {}),
|
|
500
|
+
}
|
|
440
501
|
}
|
|
441
502
|
const retained = new Set(
|
|
442
503
|
payload.retained.filter(
|
|
@@ -446,12 +507,113 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
446
507
|
const layoutData = payload.layoutData?.map((value, index) =>
|
|
447
508
|
retained.has(index) ? retainFrom.layoutData[index] : value,
|
|
448
509
|
)
|
|
449
|
-
return {
|
|
510
|
+
return {
|
|
511
|
+
data: payload.data,
|
|
512
|
+
layoutData,
|
|
513
|
+
...(payload.boundaries !== undefined ? { boundaries: payload.boundaries } : {}),
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Apply a client loader without making the server loader eager. The thunk is memoized per navigation
|
|
518
|
+
* (or per hydration), so a hook can call it repeatedly without duplicate requests. The returned
|
|
519
|
+
* client value is never sent back to the server.
|
|
520
|
+
*/
|
|
521
|
+
const applyClientLoader = async (
|
|
522
|
+
path: string,
|
|
523
|
+
route: { routeId: string; params: Record<string, string> },
|
|
524
|
+
signal: AbortSignal | undefined,
|
|
525
|
+
serverLoad: () => Promise<LoadedRouteData>,
|
|
526
|
+
initial?: LoadedRouteData,
|
|
527
|
+
): Promise<LoadedRouteData> => {
|
|
528
|
+
const hook = routeHooks[route.routeId]?.clientLoader
|
|
529
|
+
if (hook === undefined) return initial ?? serverLoad()
|
|
530
|
+
let serverResult: Promise<LoadedRouteData> | undefined =
|
|
531
|
+
initial === undefined ? undefined : Promise.resolve(initial)
|
|
532
|
+
const serverLoader = async (): Promise<unknown> => {
|
|
533
|
+
if (signal?.aborted === true)
|
|
534
|
+
throw new DOMException("The operation was aborted", "AbortError")
|
|
535
|
+
serverResult ??= serverLoad()
|
|
536
|
+
return (await serverResult).data
|
|
537
|
+
}
|
|
538
|
+
const clientData = await hook({
|
|
539
|
+
url: path,
|
|
540
|
+
params: route.params,
|
|
541
|
+
signal: signal ?? new AbortController().signal,
|
|
542
|
+
serverLoader,
|
|
543
|
+
})
|
|
544
|
+
const loaded = await serverResult
|
|
545
|
+
if (loaded === undefined) return { data: clientData }
|
|
546
|
+
// A server terminal status always wins over client-derived data. The client hook can enrich a
|
|
547
|
+
// normal response, but it cannot turn a server 404/410 into a rendered success route.
|
|
548
|
+
if (loaded.terminalRouteId !== undefined) return loaded
|
|
549
|
+
return { ...loaded, data: clientData }
|
|
450
550
|
}
|
|
451
551
|
const loadModule = options.loadModule
|
|
552
|
+
const prepareClientAction = async (
|
|
553
|
+
action: string,
|
|
554
|
+
body: ClientRequestBody,
|
|
555
|
+
signal: AbortSignal,
|
|
556
|
+
): Promise<{ readonly body: ClientRequestBody; readonly optimisticData?: unknown }> => {
|
|
557
|
+
const target = match(action)
|
|
558
|
+
if (target === null) return { body }
|
|
559
|
+
await loadModule?.(target.routeId)
|
|
560
|
+
const hook = routeHooks[target.routeId]?.clientAction
|
|
561
|
+
if (hook === undefined) return { body }
|
|
562
|
+
const result = await hook({
|
|
563
|
+
url: action,
|
|
564
|
+
params: target.params,
|
|
565
|
+
signal,
|
|
566
|
+
body,
|
|
567
|
+
serverLoader: async () => state.data,
|
|
568
|
+
})
|
|
569
|
+
if (result === undefined) return { body }
|
|
570
|
+
if (!isClientActionResult(result)) {
|
|
571
|
+
throw new TypeError("[nifra/web] clientAction must return an object or undefined")
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
body: result.body ?? body,
|
|
575
|
+
...(Object.hasOwn(result, "optimisticData") ? { optimisticData: result.optimisticData } : {}),
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
let hydrated = false
|
|
579
|
+
const hydrate = async (): Promise<void> => {
|
|
580
|
+
if (hydrated) return
|
|
581
|
+
hydrated = true
|
|
582
|
+
const route = { routeId: state.routeId, params: state.params }
|
|
583
|
+
if (routeHooks[route.routeId]?.clientLoader === undefined) return
|
|
584
|
+
const mine = ++generation
|
|
585
|
+
navAbort?.abort()
|
|
586
|
+
const ac = new AbortController()
|
|
587
|
+
navAbort = ac
|
|
588
|
+
const initial = {
|
|
589
|
+
data: state.data,
|
|
590
|
+
layoutData: state.layoutData,
|
|
591
|
+
...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
|
|
592
|
+
}
|
|
593
|
+
const loaded = await applyClientLoader(
|
|
594
|
+
state.path,
|
|
595
|
+
route,
|
|
596
|
+
ac.signal,
|
|
597
|
+
() => Promise.resolve(initial),
|
|
598
|
+
initial,
|
|
599
|
+
)
|
|
600
|
+
if (mine !== generation) return
|
|
601
|
+
if (loaded.terminalRouteId !== undefined) {
|
|
602
|
+
await loadModule?.(loaded.terminalRouteId)
|
|
603
|
+
state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null }
|
|
604
|
+
} else {
|
|
605
|
+
state = {
|
|
606
|
+
...state,
|
|
607
|
+
data: loaded.data,
|
|
608
|
+
layoutData: loaded.layoutData,
|
|
609
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
emit()
|
|
613
|
+
}
|
|
452
614
|
let state = options.initial
|
|
453
615
|
const listeners = new Set<() => void>()
|
|
454
|
-
let
|
|
616
|
+
let generation = 0
|
|
455
617
|
// Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
|
|
456
618
|
// don't do this (un/subscribe runs in effect/cleanup ticks), so the overhead is negligible
|
|
457
619
|
// (5-20 listeners typically), and the defensiveness is valuable.
|
|
@@ -472,7 +634,12 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
472
634
|
const MAX_CACHE = 50
|
|
473
635
|
const cache = new Map<
|
|
474
636
|
string,
|
|
475
|
-
{
|
|
637
|
+
{
|
|
638
|
+
data: unknown
|
|
639
|
+
layoutData?: readonly unknown[] | undefined
|
|
640
|
+
boundaries?: BoundaryStates
|
|
641
|
+
status: "fresh" | "stale"
|
|
642
|
+
}
|
|
476
643
|
>()
|
|
477
644
|
const cachePut = (path: string, loaded: LoadedRouteData): void => {
|
|
478
645
|
if (!cache.has(path) && cache.size >= MAX_CACHE) {
|
|
@@ -481,14 +648,26 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
481
648
|
}
|
|
482
649
|
// The PAIR, not just the value: navigating back to a cached route must restore its layouts' data
|
|
483
650
|
// too, or they would render empty on a hit and populated on a miss.
|
|
484
|
-
cache.set(path, {
|
|
651
|
+
cache.set(path, {
|
|
652
|
+
data: loaded.data,
|
|
653
|
+
layoutData: loaded.layoutData,
|
|
654
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
655
|
+
status: "fresh",
|
|
656
|
+
})
|
|
485
657
|
}
|
|
486
658
|
// Flip cached entries to stale (a no-op for paths not in the cache). Shared by `invalidate` and the
|
|
487
659
|
// `X-Nifra-Revalidate` handling in `submit`.
|
|
488
660
|
const markStale = (paths: readonly string[]): void => {
|
|
489
661
|
for (const p of paths) {
|
|
490
662
|
const entry = cache.get(p)
|
|
491
|
-
if (entry !== undefined)
|
|
663
|
+
if (entry !== undefined) {
|
|
664
|
+
cache.set(p, {
|
|
665
|
+
data: entry.data,
|
|
666
|
+
layoutData: entry.layoutData,
|
|
667
|
+
...(entry.boundaries !== undefined ? { boundaries: entry.boundaries } : {}),
|
|
668
|
+
status: "stale",
|
|
669
|
+
})
|
|
670
|
+
}
|
|
492
671
|
}
|
|
493
672
|
}
|
|
494
673
|
// Parse the `X-Nifra-Revalidate` response header into validated paths. The header is response data
|
|
@@ -505,27 +684,32 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
505
684
|
// (so a superseded route's NDJSON stream stops reading instead of draining in the background).
|
|
506
685
|
let navAbort: AbortController | undefined
|
|
507
686
|
|
|
508
|
-
// Refetch the active route + republish, single-flight via the shared `
|
|
687
|
+
// Refetch the active route + republish, single-flight via the shared `generation`. Shared by `invalidate`
|
|
509
688
|
// and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
|
|
510
689
|
const refetchActive = async (): Promise<void> => {
|
|
511
|
-
const mine = ++
|
|
690
|
+
const mine = ++generation
|
|
512
691
|
navAbort?.abort()
|
|
513
692
|
const ac = new AbortController()
|
|
514
693
|
navAbort = ac
|
|
515
694
|
state = { ...state, pending: true }
|
|
516
695
|
emit()
|
|
517
696
|
try {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
ac.signal,
|
|
697
|
+
const active = { routeId: state.routeId, params: state.params }
|
|
698
|
+
const loaded = await applyClientLoader(state.path, active, ac.signal, () =>
|
|
699
|
+
loadRouteData(state.path, active, ac.signal),
|
|
522
700
|
)
|
|
523
|
-
if (mine !==
|
|
701
|
+
if (mine !== generation) return // superseded - drop the stale result
|
|
524
702
|
cachePut(state.path, loaded)
|
|
525
|
-
state = {
|
|
703
|
+
state = {
|
|
704
|
+
...state,
|
|
705
|
+
data: loaded.data,
|
|
706
|
+
layoutData: loaded.layoutData,
|
|
707
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
708
|
+
pending: false,
|
|
709
|
+
}
|
|
526
710
|
emit()
|
|
527
711
|
} catch (err) {
|
|
528
|
-
if (mine ===
|
|
712
|
+
if (mine === generation) {
|
|
529
713
|
state = { ...state, pending: false }
|
|
530
714
|
emit()
|
|
531
715
|
}
|
|
@@ -564,7 +748,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
564
748
|
|
|
565
749
|
const createFetcher = (): FetcherEntry => {
|
|
566
750
|
let fState: FetcherState = { pending: false, data: undefined }
|
|
567
|
-
let
|
|
751
|
+
let fGeneration = 0
|
|
568
752
|
let fAbort: AbortController | undefined
|
|
569
753
|
let loadedPath: string | undefined
|
|
570
754
|
const fListeners = new Set<() => void>()
|
|
@@ -575,7 +759,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
575
759
|
const runLoad = async (path: string): Promise<void> => {
|
|
576
760
|
const matched = match(path)
|
|
577
761
|
if (matched === null) return // unmatched path → no-op
|
|
578
|
-
const mine = ++
|
|
762
|
+
const mine = ++fGeneration
|
|
579
763
|
fAbort?.abort()
|
|
580
764
|
const ac = new AbortController()
|
|
581
765
|
fAbort = ac
|
|
@@ -586,7 +770,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
586
770
|
loadModule?.(matched.routeId),
|
|
587
771
|
loadRouteData(path, matched, ac.signal),
|
|
588
772
|
])
|
|
589
|
-
if (mine !==
|
|
773
|
+
if (mine !== fGeneration) return // a newer load/submit on THIS fetcher superseded us
|
|
590
774
|
// Record the loaded path only on SUCCESS - a thrown or superseded load must not
|
|
591
775
|
// leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
|
|
592
776
|
// `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
|
|
@@ -595,7 +779,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
595
779
|
fState = { ...fState, data: loaded.data, pending: false }
|
|
596
780
|
fEmit()
|
|
597
781
|
} catch (err) {
|
|
598
|
-
if (mine ===
|
|
782
|
+
if (mine === fGeneration) {
|
|
599
783
|
fState = { ...fState, pending: false }
|
|
600
784
|
fEmit()
|
|
601
785
|
}
|
|
@@ -612,7 +796,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
612
796
|
},
|
|
613
797
|
load: runLoad,
|
|
614
798
|
submit: async (action, body) => {
|
|
615
|
-
const mine = ++
|
|
799
|
+
const mine = ++fGeneration
|
|
616
800
|
// Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) - like
|
|
617
801
|
// `runLoad` does for a superseding load. The mutation POST is left to complete; the
|
|
618
802
|
// signal cancels the follow-up data drain if a newer op supersedes this one.
|
|
@@ -633,7 +817,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
633
817
|
throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
|
|
634
818
|
const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
|
|
635
819
|
const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
|
|
636
|
-
if (mine !==
|
|
820
|
+
if (mine !== fGeneration) return
|
|
637
821
|
// Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
|
|
638
822
|
fState = { pending: false, data: fState.data, actionData }
|
|
639
823
|
fEmit()
|
|
@@ -644,7 +828,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
644
828
|
await refreshMounted(changed, false)
|
|
645
829
|
}
|
|
646
830
|
} catch (err) {
|
|
647
|
-
if (mine ===
|
|
831
|
+
if (mine === fGeneration) {
|
|
648
832
|
fState = { pending: false, data: fState.data }
|
|
649
833
|
fEmit()
|
|
650
834
|
}
|
|
@@ -688,44 +872,86 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
688
872
|
searchClientKeys[state.routeId] ?? [],
|
|
689
873
|
)
|
|
690
874
|
) {
|
|
691
|
-
++
|
|
875
|
+
++generation // supersede any in-flight navigation so its late result is dropped
|
|
692
876
|
navAbort?.abort()
|
|
693
|
-
cachePut(path, {
|
|
877
|
+
cachePut(path, {
|
|
878
|
+
data: state.data,
|
|
879
|
+
layoutData: state.layoutData,
|
|
880
|
+
...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
|
|
881
|
+
})
|
|
694
882
|
state = { ...state, path, pending: false, pendingPath: undefined }
|
|
695
883
|
emit()
|
|
696
884
|
return
|
|
697
885
|
}
|
|
698
|
-
const mine = ++
|
|
886
|
+
const mine = ++generation
|
|
699
887
|
navAbort?.abort() // abandon any in-flight navigation's stream
|
|
700
888
|
const ac = new AbortController()
|
|
701
889
|
navAbort = ac
|
|
702
890
|
state = { ...state, pending: true, pendingPath: path }
|
|
703
891
|
emit()
|
|
704
892
|
try {
|
|
705
|
-
//
|
|
706
|
-
//
|
|
893
|
+
// Load the route chunk before invoking a client loader. A route without a client loader still
|
|
894
|
+
// uses the same server-data path; a route with one gets a lazy `serverLoader()` thunk.
|
|
895
|
+
await loadModule?.(matched.routeId)
|
|
896
|
+
// Use prefetched server data when present (one-shot - drop it); clientLoader still runs over
|
|
897
|
+
// that cached result without causing another network request.
|
|
707
898
|
const hit = prefetched.has(path)
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
899
|
+
const prefetchedData = hit ? (prefetched.get(path) as LoadedRouteData) : undefined
|
|
900
|
+
if (hit) prefetched.delete(path)
|
|
901
|
+
const loaded = await applyClientLoader(
|
|
902
|
+
path,
|
|
903
|
+
matched,
|
|
904
|
+
ac.signal,
|
|
905
|
+
() =>
|
|
906
|
+
loadRouteData(
|
|
711
907
|
path,
|
|
712
908
|
matched,
|
|
713
909
|
ac.signal,
|
|
714
910
|
state.layoutData === undefined
|
|
715
911
|
? undefined
|
|
716
912
|
: { path: state.path, layoutData: state.layoutData },
|
|
717
|
-
)
|
|
718
|
-
|
|
719
|
-
|
|
913
|
+
),
|
|
914
|
+
prefetchedData,
|
|
915
|
+
)
|
|
720
916
|
if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
|
|
721
|
-
if (mine !==
|
|
917
|
+
if (mine !== generation) return // a newer navigation superseded this one - drop the stale result
|
|
722
918
|
cachePut(path, loaded) // keep the keyed cache coherent with what we publish
|
|
919
|
+
const interception =
|
|
920
|
+
loaded.terminalRouteId === undefined
|
|
921
|
+
? interceptionFor(state.routeId, path, matched)
|
|
922
|
+
: undefined
|
|
923
|
+
if (interception !== undefined) {
|
|
924
|
+
// The target's ordinary data request has already completed (including its server authz and
|
|
925
|
+
// optional client wrapper). Only the presentation route is retained; the current page stays
|
|
926
|
+
// mounted underneath the named slot. A target that returned a terminal status never reaches
|
|
927
|
+
// this branch, so an unauthorized/missing target cannot become a modal.
|
|
928
|
+
state = {
|
|
929
|
+
...state,
|
|
930
|
+
path,
|
|
931
|
+
boundaries: {
|
|
932
|
+
...(state.boundaries ?? {}),
|
|
933
|
+
[interception.name]: {
|
|
934
|
+
name: interception.name,
|
|
935
|
+
mode: interception.mode,
|
|
936
|
+
status: "ready",
|
|
937
|
+
data: loaded.data,
|
|
938
|
+
...(interception.errorId === undefined ? {} : { errorId: interception.errorId }),
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
actionData: undefined,
|
|
942
|
+
pending: false,
|
|
943
|
+
pendingPath: undefined,
|
|
944
|
+
}
|
|
945
|
+
emit()
|
|
946
|
+
return
|
|
947
|
+
}
|
|
723
948
|
state = {
|
|
724
949
|
routeId: loaded.terminalRouteId ?? matched.routeId,
|
|
725
950
|
params: loaded.terminalRouteId === undefined ? matched.params : {},
|
|
726
951
|
path,
|
|
727
952
|
data: loaded.data,
|
|
728
953
|
layoutData: loaded.layoutData,
|
|
954
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
729
955
|
actionData: undefined, // a fresh navigation has no action result
|
|
730
956
|
pending: false,
|
|
731
957
|
}
|
|
@@ -733,7 +959,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
733
959
|
} catch (err) {
|
|
734
960
|
// Clear our pending flag + target (only if still current) and rethrow - the caller decides how
|
|
735
961
|
// to recover (the history layer falls back to a full-page navigation).
|
|
736
|
-
if (mine ===
|
|
962
|
+
if (mine === generation) {
|
|
737
963
|
state = { ...state, pending: false, pendingPath: undefined }
|
|
738
964
|
emit()
|
|
739
965
|
}
|
|
@@ -741,7 +967,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
741
967
|
}
|
|
742
968
|
},
|
|
743
969
|
submit: async (action, body, opts) => {
|
|
744
|
-
const mine = ++
|
|
970
|
+
const mine = ++generation
|
|
971
|
+
const previousActionData = state.actionData
|
|
745
972
|
// A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
|
|
746
973
|
// fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
|
|
747
974
|
// Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
|
|
@@ -757,7 +984,16 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
757
984
|
}
|
|
758
985
|
emit()
|
|
759
986
|
try {
|
|
760
|
-
const
|
|
987
|
+
const prepared = await prepareClientAction(action, body, ac.signal)
|
|
988
|
+
if (Object.hasOwn(prepared, "optimisticData")) {
|
|
989
|
+
state = { ...state, actionData: prepared.optimisticData }
|
|
990
|
+
emit()
|
|
991
|
+
}
|
|
992
|
+
const res = await fetch(action, {
|
|
993
|
+
method: "POST",
|
|
994
|
+
body: prepared.body,
|
|
995
|
+
headers: { [DATA_HEADER]: "1" },
|
|
996
|
+
})
|
|
761
997
|
if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
|
|
762
998
|
const redirectTo = res.headers.get(REDIRECT_HEADER)
|
|
763
999
|
if (redirectTo !== null) {
|
|
@@ -765,8 +1001,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
765
1001
|
const target = match(redirectTo)
|
|
766
1002
|
if (target === null)
|
|
767
1003
|
throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
|
|
768
|
-
|
|
769
|
-
|
|
1004
|
+
await loadModule?.(target.routeId)
|
|
1005
|
+
const loaded = await applyClientLoader(redirectTo, target, ac.signal, () =>
|
|
770
1006
|
loadRouteData(
|
|
771
1007
|
redirectTo,
|
|
772
1008
|
target,
|
|
@@ -775,9 +1011,9 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
775
1011
|
? undefined
|
|
776
1012
|
: { path: state.path, layoutData: state.layoutData },
|
|
777
1013
|
),
|
|
778
|
-
|
|
1014
|
+
)
|
|
779
1015
|
if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
|
|
780
|
-
if (mine !==
|
|
1016
|
+
if (mine !== generation) return
|
|
781
1017
|
cachePut(redirectTo, loaded)
|
|
782
1018
|
state = {
|
|
783
1019
|
routeId: loaded.terminalRouteId ?? target.routeId,
|
|
@@ -785,6 +1021,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
785
1021
|
path: redirectTo,
|
|
786
1022
|
data: loaded.data,
|
|
787
1023
|
layoutData: loaded.layoutData,
|
|
1024
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
788
1025
|
actionData: undefined,
|
|
789
1026
|
pending: false,
|
|
790
1027
|
}
|
|
@@ -802,14 +1039,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
802
1039
|
// out (`revalidate: false`). A server-declared revalidate of the active path overrides the
|
|
803
1040
|
// opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
|
|
804
1041
|
const skipActive = opts?.revalidate === false && !changed.includes(state.path)
|
|
1042
|
+
const active = { routeId: state.routeId, params: state.params }
|
|
805
1043
|
const loaded = skipActive
|
|
806
|
-
? {
|
|
807
|
-
|
|
808
|
-
state.
|
|
809
|
-
{
|
|
810
|
-
|
|
1044
|
+
? {
|
|
1045
|
+
data: state.data,
|
|
1046
|
+
layoutData: state.layoutData,
|
|
1047
|
+
...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
|
|
1048
|
+
}
|
|
1049
|
+
: await applyClientLoader(state.path, active, ac.signal, () =>
|
|
1050
|
+
loadRouteData(state.path, active, ac.signal),
|
|
811
1051
|
)
|
|
812
|
-
if (mine !==
|
|
1052
|
+
if (mine !== generation) return
|
|
813
1053
|
cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
|
|
814
1054
|
// Mark the OTHER changed routes stale so the next access refetches.
|
|
815
1055
|
markStale(changed.filter((p) => p !== state.path))
|
|
@@ -821,6 +1061,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
821
1061
|
path: state.path,
|
|
822
1062
|
data: loaded.data,
|
|
823
1063
|
layoutData: loaded.layoutData,
|
|
1064
|
+
...(loaded.boundaries !== undefined ? { boundaries: loaded.boundaries } : {}),
|
|
824
1065
|
actionData,
|
|
825
1066
|
pending: false,
|
|
826
1067
|
}
|
|
@@ -829,7 +1070,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
829
1070
|
// revalidated inline above, so skip it here). Best-effort - never rejects the submit.
|
|
830
1071
|
await refreshMounted(changed, true)
|
|
831
1072
|
} catch (err) {
|
|
832
|
-
if (mine ===
|
|
1073
|
+
if (mine === generation) {
|
|
833
1074
|
// Revert: clear `submission` (the optimistic view vanishes) leaving `data` untouched, so the
|
|
834
1075
|
// pre-submit data shows through.
|
|
835
1076
|
state = {
|
|
@@ -837,7 +1078,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
837
1078
|
params: state.params,
|
|
838
1079
|
path: state.path,
|
|
839
1080
|
data: state.data,
|
|
840
|
-
actionData:
|
|
1081
|
+
actionData: previousActionData,
|
|
841
1082
|
pending: false,
|
|
842
1083
|
}
|
|
843
1084
|
emit()
|
|
@@ -845,6 +1086,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
845
1086
|
throw err
|
|
846
1087
|
}
|
|
847
1088
|
},
|
|
1089
|
+
hydrate,
|
|
848
1090
|
invalidate: async (paths) => {
|
|
849
1091
|
// Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
|
|
850
1092
|
// lazily on next access.
|