@nifrajs/web 2.13.0 → 3.1.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-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 +9 -6
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -31
- package/dist/build.js.map +1 -1
- package/dist/fn.d.ts +2 -2
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +2 -2
- package/dist/fn.js.map +1 -1
- package/dist/index.d.ts +30 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +179 -56
- package/dist/index.js.map +1 -1
- 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/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 +105 -7
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +303 -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/islands.d.ts +2 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +17 -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/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 +10 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +233 -26
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +8 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -13
- package/dist/vite.js.map +1 -1
- package/package.json +13 -2
- package/src/boundary.ts +407 -0
- package/src/build-vite.ts +5 -3
- package/src/build.ts +61 -31
- package/src/fn.ts +6 -6
- package/src/index.ts +255 -54
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +447 -70
- package/src/internal/query-runtime.ts +31 -0
- package/src/islands.ts +22 -41
- package/src/manifest.ts +50 -3
- package/src/render-seam.ts +6 -0
- package/src/router.ts +271 -29
- package/src/vite.ts +75 -13
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). */
|
|
@@ -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). */
|
|
@@ -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,9 +507,110 @@ 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 = ++token
|
|
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 !== token) 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
616
|
let token = 0
|
|
@@ -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
|
|
@@ -515,14 +694,19 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
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
701
|
if (mine !== token) 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
712
|
if (mine === token) {
|
|
@@ -690,7 +874,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
690
874
|
) {
|
|
691
875
|
++token // 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
|
|
@@ -702,30 +890,68 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
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
917
|
if (mine !== token) 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
|
}
|
|
@@ -742,6 +968,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
742
968
|
},
|
|
743
969
|
submit: async (action, body, opts) => {
|
|
744
970
|
const mine = ++token
|
|
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,7 +1011,7 @@ 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
1016
|
if (mine !== token) return
|
|
781
1017
|
cachePut(redirectTo, loaded)
|
|
@@ -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,12 +1039,15 @@ 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
1052
|
if (mine !== token) return
|
|
813
1053
|
cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
|
|
@@ -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
|
}
|
|
@@ -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.
|
package/src/vite.ts
CHANGED
|
@@ -21,7 +21,13 @@ import { createDevDiagnostics } from "./dev-diagnostics.ts"
|
|
|
21
21
|
import { listenOrExplain } from "./dev-port.ts"
|
|
22
22
|
import { discoverRoutes } from "./fs.ts"
|
|
23
23
|
import { DEFAULT_DEV_PORT, generateClientEntry, setSsrModuleLoader } from "./index.ts"
|
|
24
|
-
import {
|
|
24
|
+
import { viteDedupePackages } from "./internal/identity-policy.ts"
|
|
25
|
+
import {
|
|
26
|
+
assertIdentityParity,
|
|
27
|
+
collectIdentityParity,
|
|
28
|
+
formatIdentityParityFindings,
|
|
29
|
+
identityParityHeadline,
|
|
30
|
+
} from "./internal/parity.ts"
|
|
25
31
|
import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
|
|
26
32
|
import { importVite } from "./internal/vite-import.ts"
|
|
27
33
|
import { scopedName } from "./plugins/css-modules.ts"
|
|
@@ -34,6 +40,18 @@ interface FetchApp {
|
|
|
34
40
|
fetch(request: Request): Response | Promise<Response>
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
// A node in Vite's module graph. `importers` is the union of client and SSR importers - walking it
|
|
44
|
+
// over-invalidates (a client-only importer gets re-evaluated on the SSR side too), which is safe:
|
|
45
|
+
// re-evaluation is idempotent, and correctness of the served module beats sparing a dev re-eval.
|
|
46
|
+
interface ViteModuleNode {
|
|
47
|
+
readonly importers: Set<ViteModuleNode>
|
|
48
|
+
}
|
|
49
|
+
// Structural slice of Vite's legacy unified module graph. Absent on a Vite that renamed it (the
|
|
50
|
+
// invalidation degrades to Vite's own change handling, i.e. today's behavior) - never a hard failure.
|
|
51
|
+
interface ViteModuleGraph {
|
|
52
|
+
getModulesByFile(file: string): Set<ViteModuleNode> | undefined
|
|
53
|
+
invalidateModule(mod: ViteModuleNode): void
|
|
54
|
+
}
|
|
37
55
|
// Structural slice of the Vite dev server this module drives (avoids a hard type dep on `vite`).
|
|
38
56
|
interface ViteLike {
|
|
39
57
|
/** Load a module through VITE's graph - the seam that makes the Vite pipeline own SSR too. */
|
|
@@ -41,6 +59,8 @@ interface ViteLike {
|
|
|
41
59
|
readonly middlewares: (req: IncomingMessage, res: ServerResponse, next: () => void) => void
|
|
42
60
|
transformIndexHtml(url: string, html: string): Promise<string>
|
|
43
61
|
ssrFixStacktrace(err: Error): void
|
|
62
|
+
/** The module graph, used to invalidate a changed file AND its transitive importers before reload. */
|
|
63
|
+
readonly moduleGraph?: ViteModuleGraph
|
|
44
64
|
readonly watcher: {
|
|
45
65
|
on(event: "change" | "add" | "unlink", cb: (path: string) => void): void
|
|
46
66
|
}
|
|
@@ -99,6 +119,14 @@ export interface ViteDevServerOptions {
|
|
|
99
119
|
readonly publicDir?: string | false
|
|
100
120
|
/** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
|
|
101
121
|
readonly publicEnvPrefix?: string
|
|
122
|
+
/**
|
|
123
|
+
* Downgrade the startup identity-parity check from a hard failure to a loud warning (dev only).
|
|
124
|
+
* The check catches two physical copies of an identity-sensitive package (e.g. React) resolving in
|
|
125
|
+
* one process, which reliably breaks hydration and framework context. When a duplicate comes from a
|
|
126
|
+
* linked sibling repo you cannot fix in the moment, set this to keep the dev server running while you
|
|
127
|
+
* resolve it; `nifra build` never honors it. Wired to `nifra dev --allow-duplicate-identity`.
|
|
128
|
+
*/
|
|
129
|
+
readonly allowDuplicateIdentity?: boolean
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
export interface ViteDevServer {
|
|
@@ -297,7 +325,16 @@ function packageNameOf(specifier: string): string | undefined {
|
|
|
297
325
|
export async function createViteDevServer(options: ViteDevServerOptions): Promise<ViteDevServer> {
|
|
298
326
|
const root = resolvePath(options.root ?? process.cwd())
|
|
299
327
|
const routesDir = resolvePath(options.routesDir)
|
|
300
|
-
|
|
328
|
+
if (options.allowDuplicateIdentity === true) {
|
|
329
|
+
const parity = await collectIdentityParity(root)
|
|
330
|
+
if (parity.findings.length > 0)
|
|
331
|
+
console.warn(
|
|
332
|
+
`[nifra] identity parity bypassed via --allow-duplicate-identity (${identityParityHeadline(parity.findings.length)}):\n${formatIdentityParityFindings(parity.findings)}\n` +
|
|
333
|
+
"[nifra] dev is continuing, but duplicate module identity can break hydration and framework context - fix the resolution before you ship.",
|
|
334
|
+
)
|
|
335
|
+
} else {
|
|
336
|
+
await assertIdentityParity(root)
|
|
337
|
+
}
|
|
301
338
|
const port = options.port ?? DEFAULT_DEV_PORT
|
|
302
339
|
|
|
303
340
|
// Which files are the app's own components, for framework plugins that hot-patch at component
|
|
@@ -431,12 +468,13 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
431
468
|
plugins: [viteServerFnStub(), viteServerOnlyEmpty(), ...plugins],
|
|
432
469
|
resolve: {
|
|
433
470
|
conditions: resolveConditions,
|
|
434
|
-
// Dedupe
|
|
435
|
-
// from a SIBLING app's node_modules, so the dev server
|
|
436
|
-
// dispatcher → `resolveDispatcher().useState` null
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
471
|
+
// Dedupe each framework runtime to ONE copy. In a multi-root workspace a shared package can pull
|
|
472
|
+
// react/react-dom (or preact, or svelte) from a SIBLING app's node_modules, so the dev server
|
|
473
|
+
// loads two cores → a second hook dispatcher/options global → `resolveDispatcher().useState` null
|
|
474
|
+
// on any hook-using route (the error points at the component, not the resolution - brutal to
|
|
475
|
+
// diagnose). Same policy table the production builds read, so dev matches prod. No-op per
|
|
476
|
+
// framework the app does not use (the package simply isn't present to dedupe).
|
|
477
|
+
dedupe: [...viteDedupePackages()],
|
|
440
478
|
},
|
|
441
479
|
ssr: {
|
|
442
480
|
// Explicit listing forces external even for workspace-LINKED packages (Vite's default keeps
|
|
@@ -474,12 +512,36 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
474
512
|
setSsrModuleLoader(ssrLoad)
|
|
475
513
|
app = await options.createApp(entryUrl, ssrLoad)
|
|
476
514
|
|
|
515
|
+
// Evict a changed file from the SSR graph together with every module that (transitively) imports it,
|
|
516
|
+
// BEFORE re-creating the app. Vite re-evaluates a directly-changed module on its own, but a parent
|
|
517
|
+
// that merely imports the changed leaf keeps its cached SSR bindings - so the re-imported entry walks
|
|
518
|
+
// a graph that is fresh at the leaf and stale everywhere above it. That is what surfaces downstream as
|
|
519
|
+
// phantom hydration mismatches (SSR renders through an old module, the client through the new one) and
|
|
520
|
+
// stale i18n catalogs. Invalidating the importer closure makes the next `ssrLoadModule(entry)` re-walk
|
|
521
|
+
// the whole affected subtree. `importers` is the client+SSR union, so this over-invalidates slightly;
|
|
522
|
+
// in a dev server that only costs a re-evaluation, never correctness.
|
|
523
|
+
const invalidateImporterClosure = (path: string): void => {
|
|
524
|
+
const graph = vite.moduleGraph
|
|
525
|
+
if (graph === undefined) return
|
|
526
|
+
const roots = graph.getModulesByFile(path)
|
|
527
|
+
if (roots === undefined) return
|
|
528
|
+
const seen = new Set<ViteModuleNode>()
|
|
529
|
+
const stack = [...roots]
|
|
530
|
+
for (let mod = stack.pop(); mod !== undefined; mod = stack.pop()) {
|
|
531
|
+
if (seen.has(mod)) continue
|
|
532
|
+
seen.add(mod)
|
|
533
|
+
graph.invalidateModule(mod)
|
|
534
|
+
for (const importer of mod.importers) stack.push(importer)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
477
538
|
// Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
|
|
478
|
-
// from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT
|
|
479
|
-
//
|
|
480
|
-
// the
|
|
539
|
+
// from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT is re-evaluated by
|
|
540
|
+
// Vite plus the importer-closure invalidation above; no version counter against Bun's import cache is
|
|
541
|
+
// needed anymore, the way the old `importQuery` cache-buster was.
|
|
481
542
|
let refreshVersion = 0
|
|
482
|
-
const refreshApp = (): void => {
|
|
543
|
+
const refreshApp = (path?: string): void => {
|
|
544
|
+
if (path !== undefined) invalidateImporterClosure(path)
|
|
483
545
|
const version = ++refreshVersion
|
|
484
546
|
Promise.resolve(options.createApp(entryUrl, ssrLoad))
|
|
485
547
|
.then((next) => {
|
|
@@ -491,7 +553,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
491
553
|
for (const event of ["add", "unlink"] as const) {
|
|
492
554
|
vite.watcher.on(event, (path) => {
|
|
493
555
|
if (path.startsWith(`${routesDir}/`) || path.startsWith(`${routesDir}\\`)) writeClientEntry()
|
|
494
|
-
refreshApp()
|
|
556
|
+
refreshApp(path)
|
|
495
557
|
})
|
|
496
558
|
}
|
|
497
559
|
|