@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.
Files changed (145) 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-plan.d.ts +228 -0
  6. package/dist/build-plan.d.ts.map +1 -0
  7. package/dist/build-plan.js +514 -0
  8. package/dist/build-plan.js.map +1 -0
  9. package/dist/build-vite.d.ts +2 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +5 -3
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +2 -197
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +92 -515
  16. package/dist/build.js.map +1 -1
  17. package/dist/chunk-names.d.ts.map +1 -1
  18. package/dist/chunk-names.js +3 -2
  19. package/dist/chunk-names.js.map +1 -1
  20. package/dist/dev-ssr-graph.d.ts.map +1 -1
  21. package/dist/dev-ssr-graph.js +63 -28
  22. package/dist/dev-ssr-graph.js.map +1 -1
  23. package/dist/diagnostic.d.ts.map +1 -1
  24. package/dist/diagnostic.js +9 -2
  25. package/dist/diagnostic.js.map +1 -1
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +3 -1
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/fs.d.ts.map +1 -1
  30. package/dist/fs.js +2 -1
  31. package/dist/fs.js.map +1 -1
  32. package/dist/index.d.ts +16 -589
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +23 -1824
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/codegen.d.ts +68 -0
  37. package/dist/internal/codegen.d.ts.map +1 -0
  38. package/dist/internal/codegen.js +317 -0
  39. package/dist/internal/codegen.js.map +1 -0
  40. package/dist/internal/fetcher-runtime.d.ts +19 -0
  41. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  42. package/dist/internal/fetcher-runtime.js +26 -0
  43. package/dist/internal/fetcher-runtime.js.map +1 -0
  44. package/dist/internal/head-merge.d.ts +2 -0
  45. package/dist/internal/head-merge.d.ts.map +1 -1
  46. package/dist/internal/head-merge.js +17 -1
  47. package/dist/internal/head-merge.js.map +1 -1
  48. package/dist/internal/identity-policy.d.ts +51 -0
  49. package/dist/internal/identity-policy.d.ts.map +1 -0
  50. package/dist/internal/identity-policy.js +67 -0
  51. package/dist/internal/identity-policy.js.map +1 -0
  52. package/dist/internal/parity.d.ts +104 -8
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +472 -56
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/query-runtime.d.ts +8 -0
  57. package/dist/internal/query-runtime.d.ts.map +1 -0
  58. package/dist/internal/query-runtime.js +29 -0
  59. package/dist/internal/query-runtime.js.map +1 -0
  60. package/dist/internal/render-document.d.ts +372 -0
  61. package/dist/internal/render-document.d.ts.map +1 -0
  62. package/dist/internal/render-document.js +744 -0
  63. package/dist/internal/render-document.js.map +1 -0
  64. package/dist/internal/runtime-contract.d.ts +48 -0
  65. package/dist/internal/runtime-contract.d.ts.map +1 -0
  66. package/dist/internal/runtime-contract.js +27 -0
  67. package/dist/internal/runtime-contract.js.map +1 -0
  68. package/dist/internal/web-app.d.ts +171 -0
  69. package/dist/internal/web-app.d.ts.map +1 -0
  70. package/dist/internal/web-app.js +844 -0
  71. package/dist/internal/web-app.js.map +1 -0
  72. package/dist/islands.d.ts +50 -2
  73. package/dist/islands.d.ts.map +1 -1
  74. package/dist/islands.js +96 -36
  75. package/dist/islands.js.map +1 -1
  76. package/dist/manifest.d.ts +41 -6
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js.map +1 -1
  79. package/dist/module-graph.d.ts.map +1 -1
  80. package/dist/module-graph.js +22 -9
  81. package/dist/module-graph.js.map +1 -1
  82. package/dist/nano.d.ts +125 -0
  83. package/dist/nano.d.ts.map +1 -0
  84. package/dist/nano.js +190 -0
  85. package/dist/nano.js.map +1 -0
  86. package/dist/plugins/css-modules.js +2 -2
  87. package/dist/plugins/css-modules.js.map +1 -1
  88. package/dist/plugins/kit.d.ts +12 -0
  89. package/dist/plugins/kit.d.ts.map +1 -1
  90. package/dist/plugins/kit.js +62 -12
  91. package/dist/plugins/kit.js.map +1 -1
  92. package/dist/plugins/postcss.js +2 -2
  93. package/dist/plugins/postcss.js.map +1 -1
  94. package/dist/plugins/scss.js +2 -2
  95. package/dist/plugins/scss.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts +70 -0
  97. package/dist/plugins/stylex.d.ts.map +1 -0
  98. package/dist/plugins/stylex.js +166 -0
  99. package/dist/plugins/stylex.js.map +1 -0
  100. package/dist/plugins/svg.js +2 -2
  101. package/dist/plugins/svg.js.map +1 -1
  102. package/dist/render-seam.d.ts +5 -0
  103. package/dist/render-seam.d.ts.map +1 -1
  104. package/dist/render-seam.js +10 -0
  105. package/dist/render-seam.js.map +1 -1
  106. package/dist/router.d.ts +11 -1
  107. package/dist/router.d.ts.map +1 -1
  108. package/dist/router.js +254 -47
  109. package/dist/router.js.map +1 -1
  110. package/dist/vite.d.ts.map +1 -1
  111. package/dist/vite.js +34 -16
  112. package/dist/vite.js.map +1 -1
  113. package/package.json +49 -2
  114. package/src/boundary.ts +407 -0
  115. package/src/build-plan.ts +741 -0
  116. package/src/build-vite.ts +6 -7
  117. package/src/build.ts +120 -730
  118. package/src/chunk-names.ts +3 -2
  119. package/src/dev-ssr-graph.ts +63 -28
  120. package/src/diagnostic.ts +14 -2
  121. package/src/fonts-google.ts +3 -1
  122. package/src/fs.ts +2 -1
  123. package/src/index.ts +107 -2553
  124. package/src/internal/codegen.ts +400 -0
  125. package/src/internal/fetcher-runtime.ts +36 -0
  126. package/src/internal/head-merge.ts +19 -1
  127. package/src/internal/identity-policy.ts +94 -0
  128. package/src/internal/parity.ts +603 -71
  129. package/src/internal/query-runtime.ts +31 -0
  130. package/src/internal/render-document.ts +1116 -0
  131. package/src/internal/runtime-contract.ts +54 -0
  132. package/src/internal/web-app.ts +1114 -0
  133. package/src/islands.ts +106 -40
  134. package/src/manifest.ts +50 -3
  135. package/src/module-graph.ts +23 -9
  136. package/src/nano.ts +246 -0
  137. package/src/plugins/css-modules.ts +2 -2
  138. package/src/plugins/kit.ts +65 -12
  139. package/src/plugins/postcss.ts +2 -2
  140. package/src/plugins/scss.ts +2 -2
  141. package/src/plugins/stylex.ts +298 -0
  142. package/src/plugins/svg.ts +2 -2
  143. package/src/render-seam.ts +6 -0
  144. package/src/router.ts +293 -51
  145. 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 token), so N row-level mutations / side-channel loads can be in flight at once without
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 token so that when
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 { 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,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 { 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 = ++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 token = 0
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
- { 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
@@ -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 `token`. Shared by `invalidate`
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 = ++token
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 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
- if (mine !== token) return // superseded - drop the stale result
701
+ if (mine !== generation) 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
- if (mine === token) {
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 fToken = 0
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 = ++fToken
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 !== fToken) return // a newer load/submit on THIS fetcher superseded us
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 === fToken) {
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 = ++fToken
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 !== fToken) return
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 === fToken) {
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
- ++token // supersede any in-flight navigation so its late result is dropped
875
+ ++generation // 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
697
885
  }
698
- const mine = ++token
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
- // 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
- if (mine !== token) return // a newer navigation superseded this one - drop the stale result
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 === token) {
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 = ++token
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 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,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 !== token) return
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
- ? { 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
- if (mine !== token) return
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 === token) {
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: 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.