@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.
Files changed (69) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +9 -6
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +57 -31
  11. package/dist/build.js.map +1 -1
  12. package/dist/fn.d.ts +2 -2
  13. package/dist/fn.d.ts.map +1 -1
  14. package/dist/fn.js +2 -2
  15. package/dist/fn.js.map +1 -1
  16. package/dist/index.d.ts +30 -20
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +179 -56
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/fetcher-runtime.d.ts +19 -0
  21. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  22. package/dist/internal/fetcher-runtime.js +26 -0
  23. package/dist/internal/fetcher-runtime.js.map +1 -0
  24. package/dist/internal/identity-policy.d.ts +51 -0
  25. package/dist/internal/identity-policy.d.ts.map +1 -0
  26. package/dist/internal/identity-policy.js +67 -0
  27. package/dist/internal/identity-policy.js.map +1 -0
  28. package/dist/internal/parity.d.ts +105 -7
  29. package/dist/internal/parity.d.ts.map +1 -1
  30. package/dist/internal/parity.js +303 -56
  31. package/dist/internal/parity.js.map +1 -1
  32. package/dist/internal/query-runtime.d.ts +8 -0
  33. package/dist/internal/query-runtime.d.ts.map +1 -0
  34. package/dist/internal/query-runtime.js +29 -0
  35. package/dist/internal/query-runtime.js.map +1 -0
  36. package/dist/islands.d.ts +2 -2
  37. package/dist/islands.d.ts.map +1 -1
  38. package/dist/islands.js +17 -36
  39. package/dist/islands.js.map +1 -1
  40. package/dist/manifest.d.ts +41 -6
  41. package/dist/manifest.d.ts.map +1 -1
  42. package/dist/manifest.js.map +1 -1
  43. package/dist/render-seam.d.ts +5 -0
  44. package/dist/render-seam.d.ts.map +1 -1
  45. package/dist/render-seam.js +10 -0
  46. package/dist/render-seam.js.map +1 -1
  47. package/dist/router.d.ts +10 -0
  48. package/dist/router.d.ts.map +1 -1
  49. package/dist/router.js +233 -26
  50. package/dist/router.js.map +1 -1
  51. package/dist/vite.d.ts +8 -0
  52. package/dist/vite.d.ts.map +1 -1
  53. package/dist/vite.js +51 -13
  54. package/dist/vite.js.map +1 -1
  55. package/package.json +13 -2
  56. package/src/boundary.ts +407 -0
  57. package/src/build-vite.ts +5 -3
  58. package/src/build.ts +61 -31
  59. package/src/fn.ts +6 -6
  60. package/src/index.ts +255 -54
  61. package/src/internal/fetcher-runtime.ts +36 -0
  62. package/src/internal/identity-policy.ts +94 -0
  63. package/src/internal/parity.ts +447 -70
  64. package/src/internal/query-runtime.ts +31 -0
  65. package/src/islands.ts +22 -41
  66. package/src/manifest.ts +50 -3
  67. package/src/render-seam.ts +6 -0
  68. package/src/router.ts +271 -29
  69. 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 { data: payload.data, layoutData: payload.layoutData }
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 { data: payload.data, layoutData }
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
- { data: unknown; layoutData?: readonly unknown[] | undefined; status: "fresh" | "stale" }
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, { data: loaded.data, layoutData: loaded.layoutData, status: "fresh" })
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) cache.set(p, { data: entry.data, status: "stale" })
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 loaded = await loadRouteData(
519
- state.path,
520
- { routeId: state.routeId, params: state.params },
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 = { ...state, data: loaded.data, layoutData: loaded.layoutData, pending: false }
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, { data: state.data, layoutData: state.layoutData })
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
- // Use prefetched data when present (one-shot - drop it); else fetch. The chunk is loaded
706
- // either way (cached + instant after a prefetch), so pending covers both.
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 dataPromise = hit
709
- ? Promise.resolve(prefetched.get(path) as LoadedRouteData)
710
- : loadRouteData(
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
- if (hit) prefetched.delete(path)
719
- const [, loaded] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
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 res = await fetch(action, { method: "POST", body, headers: { [DATA_HEADER]: "1" } })
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
- const [, loaded] = await Promise.all([
769
- loadModule?.(target.routeId),
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
- ? { data: state.data, layoutData: state.layoutData }
807
- : await loadRouteData(
808
- state.path,
809
- { routeId: state.routeId, params: state.params },
810
- ac.signal,
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: state.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 { assertIdentityParity } from "./internal/parity.ts"
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
- await assertIdentityParity(root)
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 React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
435
- // from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
436
- // dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
437
- // component, not the resolution - brutal to diagnose). Mirrors the build-time reactDedupePlugin so
438
- // dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
439
- dedupe: ["react", "react-dom"],
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 no longer needs a
479
- // version counter: Vite owns SSR resolution now and re-evaluates changed modules itself, which is
480
- // the cache-busting the old `importQuery` was emulating against Bun's import cache.
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