@wular/pnext 0.1.4 → 0.1.6

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 (47) hide show
  1. package/config/ts/react.json +2 -1
  2. package/package.json +1 -1
  3. package/reference/navigation.md +3 -1
  4. package/src/api/client-navigation.ts +53 -4
  5. package/src/api/link.tsx +6 -7
  6. package/src/cli/adapters/vercel-warm.ts +6 -1
  7. package/src/cli/adapters/vercel.ts +1 -1
  8. package/src/cli/build/cache.ts +8 -3
  9. package/src/cli/build.ts +5 -12
  10. package/src/cli/index.ts +1 -8
  11. package/src/cli/serve/entry.ts +16 -161
  12. package/src/cli/serve/pipeline.ts +11 -3
  13. package/src/cli/start.ts +1 -1
  14. package/src/cli/typegen.ts +65 -3
  15. package/src/client/build.ts +10 -7
  16. package/src/client/entry.ts +50 -29
  17. package/src/client/reference-stub.ts +1 -1
  18. package/src/client/router/hub.ts +13 -11
  19. package/src/client/router/page-slot.ts +58 -0
  20. package/src/client/router/runtime.ts +377 -388
  21. package/src/compat/bundler/symlink-imports.ts +1 -1
  22. package/src/compat/client/errors/bare-boundary.ts +1 -1
  23. package/src/compat/client/errors/error-boundary.ts +1 -1
  24. package/src/compat/client/errors/global-error.ts +40 -79
  25. package/src/compat/client/errors/install.ts +1 -1
  26. package/src/compat/client/errors/lazy.ts +7 -0
  27. package/src/compat/client/errors/primitive-throw.ts +36 -53
  28. package/src/compat/client/segment-cache-policy.ts +5 -22
  29. package/src/compat/client/segment-cache.ts +7 -26
  30. package/src/compat/css/modules.ts +1 -7
  31. package/src/compat/css/sass.ts +1 -7
  32. package/src/compat/react/parity.ts +1 -1
  33. package/src/compat/register/bundler.ts +9 -1
  34. package/src/compat/tsconfig-defaults.ts +4 -7
  35. package/src/compat-bootstrap.ts +15 -0
  36. package/src/css/build.ts +18 -15
  37. package/src/dev/server.ts +2 -2
  38. package/src/index.ts +2 -0
  39. package/src/render/renderer.ts +209 -24
  40. package/src/render/resource-hints.ts +4 -1
  41. package/src/resolve/imports.ts +13 -8
  42. package/src/routing/routes.ts +32 -18
  43. package/src/runtime/loader.ts +29 -10
  44. package/src/runtime/modules.ts +28 -7
  45. package/src/runtime/vendor-build.ts +2 -2
  46. package/src/types.ts +2 -0
  47. package/src/utils/fs.ts +122 -0
@@ -49,7 +49,15 @@ import type {
49
49
  SoftNavigateOptions,
50
50
  } from './types'
51
51
  import type { LinkClickTarget } from './hub'
52
- import { elementInPageSlot, graftPageSlot, loadingShellTarget, pageSlotRange } from './page-slot'
52
+ import {
53
+ childSegment,
54
+ divergingLayout,
55
+ elementInPageSlot,
56
+ graftSegmentChildren,
57
+ loadingShellTarget,
58
+ pageSlotRange,
59
+ segmentKey,
60
+ } from './page-slot'
53
61
  // ---------------------------------------------------------------------------
54
62
  // DOCUMENTS
55
63
  // ---------------------------------------------------------------------------
@@ -140,12 +148,7 @@ export function routeParamBoundaryChanged(
140
148
  }
141
149
 
142
150
  function stableParams(params: Record<string, string | string[]> | undefined): string {
143
- if (!params) return ''
144
- return JSON.stringify(
145
- Object.keys(params)
146
- .sort()
147
- .map(key => [key, params[key]]),
148
- )
151
+ return params ? slotsStateKey(params as Record<string, string>) : ''
149
152
  }
150
153
 
151
154
  // Importing a route's entry module during a navigation — only the navigation
@@ -516,6 +519,9 @@ function installStylesheets(doc: Document): Promise<void>[] {
516
519
  return pending
517
520
  }
518
521
 
522
+ // React holds every commit until its route sheets load, so an early paint hands its loads to `hold`.
523
+ type SheetHold = (sheets: Promise<void>[]) => void
524
+
519
525
  function absoluteStylesheetHref(href: string) {
520
526
  try {
521
527
  return new URL(href, location.href).href
@@ -569,11 +575,25 @@ function syncHeadMetadata(doc: Document) {
569
575
  if (managedHeadNode(node)) node.remove()
570
576
  }
571
577
  for (const node of [...doc.head.childNodes]) {
572
- if (managedHeadNode(node)) document.head.append(document.importNode(node, true))
578
+ if (managedHeadNode(node) || newResourceHint(node))
579
+ document.head.append(document.importNode(node, true))
573
580
  }
574
581
  document.title = titleText ?? ''
575
582
  }
576
583
 
584
+ // Hints persist like React's resources: installed once per variant, never pruned.
585
+ function newResourceHint(node: ChildNode) {
586
+ const key = (link: HTMLLinkElement) =>
587
+ ['rel', 'href', 'as', 'imagesrcset', 'imagesizes', 'media']
588
+ .map(name => link.getAttribute(name))
589
+ .join('\n') + link.crossOrigin
590
+ return (
591
+ node instanceof HTMLLinkElement &&
592
+ /^(preload|modulepreload|preconnect|dns-prefetch)$/.test(node.rel) &&
593
+ ![...document.head.querySelectorAll('link')].some(link => key(link) === key(node))
594
+ )
595
+ }
596
+
577
597
  function managedHeadNode(node: ChildNode) {
578
598
  if (node instanceof HTMLTitleElement) return true
579
599
  if (node instanceof HTMLMetaElement) return true
@@ -688,6 +708,8 @@ function islandMarkerRange(start: Comment, endData: string) {
688
708
  }
689
709
 
690
710
  const SEGMENT_PRESERVE_ATTRIBUTE = 'data-pnext-preserve-segment'
711
+ // A layout a loading shell painted: a copy of the incoming one, never a live layout to keep.
712
+ const SHELL_LAYOUT_ATTRIBUTE = 'data-pnext-shell-layout'
691
713
 
692
714
  function isTopLevelServerSegment(segment: Element) {
693
715
  return segment.parentElement?.closest('pnext-layout[data-pnext-segment]') == null
@@ -695,36 +717,65 @@ function isTopLevelServerSegment(segment: Element) {
695
717
 
696
718
  function serverSegmentSignature(segment: Element) {
697
719
  return [segment, ...segment.querySelectorAll('pnext-layout[data-pnext-segment]')]
698
- .map(
699
- child =>
700
- `${child.getAttribute('data-pnext-segment') ?? ''}\u0001${child.getAttribute('data-pnext-scope') ?? ''}`,
701
- )
720
+ .map(segmentKey)
702
721
  .join('\u0000')
703
722
  }
704
723
 
724
+ // Whether a kept layout's routed child (child layout, else page slot) is missing or inside an island.
725
+ function routedUnderIsland(leaf: Element, child: Element | null) {
726
+ const parent = (child ?? pageSlotRange(leaf)?.[0])?.parentElement
727
+ return !parent || !!parent.closest('pnext-client')
728
+ }
729
+
705
730
  // Drop any segment-preserve tags left on a document by an earlier pass (the
706
731
  // ungraftable dry run tags the same nodes). Every real pass re-tags from
707
732
  // scratch, so stale indices never leak into the graft.
708
- function clearSegmentPreserveTags(doc: Document) {
709
- for (const tagged of doc.body.querySelectorAll(`[${SEGMENT_PRESERVE_ATTRIBUTE}]`)) {
733
+ function clearSegmentPreserveTags(root: ParentNode) {
734
+ for (const tagged of root.querySelectorAll(`[${SEGMENT_PRESERVE_ATTRIBUTE}]`)) {
710
735
  tagged.removeAttribute(SEGMENT_PRESERVE_ATTRIBUTE)
711
736
  }
712
737
  }
713
738
 
739
+ // Tags the incoming layouts that keep their live counterpart: the shared prefix of each top-level
740
+ // chain, matched on identity and scope. A routed child in an island's DOM needs the full chain (0.1.4).
741
+ // Returns the live layouts by tag index.
714
742
  function matchPreservedServerSegments(doc: Document): Element[] {
715
743
  clearSegmentPreserveTags(doc)
716
- const liveBySignature = new Map<string, Element>()
744
+ const liveByKey = new Map<string, Element>()
717
745
  for (const segment of document.body.querySelectorAll('pnext-layout[data-pnext-segment]')) {
718
- if (!isTopLevelServerSegment(segment)) continue
719
- liveBySignature.set(serverSegmentSignature(segment), segment)
746
+ // The paint hold is an inert copy of layouts that are still live.
747
+ if (
748
+ isTopLevelServerSegment(segment) &&
749
+ !segment.closest('[data-pnext-navigation-paint-hold]') &&
750
+ !segment.hasAttribute(SHELL_LAYOUT_ATTRIBUTE)
751
+ )
752
+ liveByKey.set(segmentKey(segment), segment)
720
753
  }
721
754
  const preserved: Element[] = []
722
755
  for (const segment of doc.body.querySelectorAll('pnext-layout[data-pnext-segment]')) {
723
- if (!isTopLevelServerSegment(segment)) continue
724
- const live = liveBySignature.get(serverSegmentSignature(segment))
725
- if (!live) continue
726
- segment.setAttribute(SEGMENT_PRESERVE_ATTRIBUTE, String(preserved.length))
727
- preserved.push(live)
756
+ const top = isTopLevelServerSegment(segment) && liveByKey.get(segmentKey(segment))
757
+ if (!top) continue
758
+ const kept: [Element, Element][] = []
759
+ let [live, incoming]: [Element | null, Element | null] = [top, segment]
760
+ while (
761
+ live &&
762
+ incoming &&
763
+ segmentKey(live) === segmentKey(incoming) &&
764
+ !live.hasAttribute(SHELL_LAYOUT_ATTRIBUTE)
765
+ ) {
766
+ kept.push([live, incoming])
767
+ ;[live, incoming] = [childSegment(live), childSegment(incoming)]
768
+ }
769
+ const [liveLeaf, incomingLeaf] = kept[kept.length - 1]!
770
+ if (
771
+ (routedUnderIsland(liveLeaf, live) || routedUnderIsland(incomingLeaf, incoming)) &&
772
+ serverSegmentSignature(top) !== serverSegmentSignature(segment)
773
+ )
774
+ continue
775
+ for (const [layout, tagged] of kept) {
776
+ tagged.setAttribute(SEGMENT_PRESERVE_ATTRIBUTE, String(preserved.length))
777
+ preserved.push(layout)
778
+ }
728
779
  }
729
780
  return preserved
730
781
  }
@@ -732,12 +783,18 @@ function matchPreservedServerSegments(doc: Document): Element[] {
732
783
  function preservedSegmentIslands(segments: Element[]): LiveIslandRoot[] {
733
784
  const roots: LiveIslandRoot[] = []
734
785
  for (const segment of segments) {
786
+ if (!isTopLevelServerSegment(segment)) continue
787
+ // The child layout the graft swaps out takes its islands with it.
788
+ let departing = childSegment(segment)
789
+ while (departing && segments.includes(departing)) departing = childSegment(departing)
735
790
  for (const root of segment.querySelectorAll('pnext-client[data-pnext-client]')) {
736
791
  const live = root as LiveIslandRoot
737
792
  if (!isTopLevelIslandRoot(root)) continue
738
793
  if (elementInPageSlot(root)) continue
794
+ if (departing?.contains(root)) continue
739
795
  if (!live.__pnextLive) continue
740
- live.__pnextIncoming ??= live.cloneNode(true) as Element
796
+ // Kept as is: a clone of hydrated DOM has lost its nested island hosts.
797
+ live.__pnextIncoming ??= live
741
798
  roots.push(live)
742
799
  }
743
800
  }
@@ -745,18 +802,24 @@ function preservedSegmentIslands(segments: Element[]): LiveIslandRoot[] {
745
802
  }
746
803
 
747
804
  function graftPreservedServerSegments(fragment: DocumentFragment, preserved: Element[]) {
805
+ const liveFor = (placeholder: Element | null) =>
806
+ preserved[Number(placeholder?.getAttribute(SEGMENT_PRESERVE_ATTRIBUTE) ?? NaN)]
748
807
  for (const placeholder of [...fragment.querySelectorAll(`[${SEGMENT_PRESERVE_ATTRIBUTE}]`)]) {
749
- const live = preserved[Number(placeholder.getAttribute(SEGMENT_PRESERVE_ATTRIBUTE))]
750
- placeholder.removeAttribute(SEGMENT_PRESERVE_ATTRIBUTE)
751
- if (!live) continue
752
- // MOVE (not clone) the incoming page slot: it already belongs to this
808
+ const live = liveFor(placeholder)
809
+ if (!live || !isTopLevelServerSegment(placeholder)) continue
810
+ // The deepest kept layout takes the incoming children.
811
+ let [liveLeaf, incomingLeaf] = [live, placeholder]
812
+ for (let next = childSegment(incomingLeaf); liveFor(next); next = childSegment(incomingLeaf))
813
+ [liveLeaf, incomingLeaf] = [liveFor(next)!, next!]
814
+ // MOVE (not clone) the incoming children: they already belong to this
753
815
  // document, and cloning would turn an island root the island graft just
754
816
  // spliced in back into an inert copy, dropping its live preact tree. Moving
755
817
  // the slot itself also preserves whether this destination needs a mount
756
818
  // element or Next-flat comment anchors.
757
- if (!graftPageSlot(live, placeholder)) continue
819
+ if (!graftSegmentChildren(liveLeaf, incomingLeaf)) continue
758
820
  placeholder.replaceWith(live)
759
821
  }
822
+ clearSegmentPreserveTags(fragment)
760
823
  }
761
824
 
762
825
  const SEGMENT_SKIP_ATTRIBUTE = 'data-pnext-skip'
@@ -772,7 +835,7 @@ function skippedSegmentsUngraftable(doc: Document, refreshLike: boolean): boolea
772
835
  // Dry-run the segment match: it tags placeholders deterministically, so the
773
836
  // later real pass re-tags them identically.
774
837
  matchPreservedServerSegments(doc)
775
- return skipped.some(segment => !segment.closest(`pnext-layout[${SEGMENT_PRESERVE_ATTRIBUTE}]`))
838
+ return skipped.some(segment => !segment.hasAttribute(SEGMENT_PRESERVE_ATTRIBUTE))
776
839
  }
777
840
 
778
841
  // Shared-layout island preservation. An island root whose island renders again in
@@ -804,30 +867,23 @@ function isTopLevelIslandRoot(root: Element) {
804
867
  // The rendered source path of a document's children slot (from its embedded
805
868
  // nav-state script), used to decide template remounts across a swap.
806
869
  function docNavStateChildren(doc: Document, fallback: string): string {
807
- const text = doc.getElementById?.('__PNEXT_NAV_STATE__')?.textContent
808
- if (text) {
809
- try {
810
- const parsed = JSON.parse(text) as DocumentNavState
811
- if (parsed.children) return parsed.children
812
- } catch {
813
- // Malformed state — fall back to the navigation target.
814
- }
815
- }
816
- return fallback
870
+ return docNavState(doc)?.children || fallback
817
871
  }
818
872
 
819
873
  // The per-slot source paths a document's embedded nav state records.
820
874
  function docNavStateSlots(doc: Document): Record<string, string> {
875
+ return docNavState(doc)?.slots || {}
876
+ }
877
+
878
+ function docNavState(doc: Document): DocumentNavState | null | undefined {
821
879
  const text = doc.getElementById?.('__PNEXT_NAV_STATE__')?.textContent
822
880
  if (text) {
823
881
  try {
824
- const parsed = JSON.parse(text) as DocumentNavState
825
- if (parsed.slots) return parsed.slots
882
+ return JSON.parse(text) as DocumentNavState | null
826
883
  } catch {
827
- // Malformed state — treat as empty slot state.
884
+ // Malformed state — callers fall back.
828
885
  }
829
886
  }
830
- return {}
831
887
  }
832
888
 
833
889
  export function slotsStateKey(slots: Record<string, string>): string {
@@ -1638,6 +1694,11 @@ function storeNavState() {
1638
1694
  const entryDocCache = new Map<string, PrefetchedPage>()
1639
1695
  const ENTRY_DOC_CACHE_LIMIT = 12
1640
1696
 
1697
+ // Drop insertion-order-oldest entries beyond `limit` (keys are never undefined).
1698
+ function trimOldest(cache: Map<string, unknown> | Set<string>, limit: number): void {
1699
+ while (cache.size > limit) cache.delete(cache.keys().next().value!)
1700
+ }
1701
+
1641
1702
  // Back/forward document cache, keyed by ROUTE (pathname+search). Every shown
1642
1703
  // document lands here, and a `prefetch={true}` full prefetch reads it before issuing
1643
1704
  // any request. Only complete documents are kept (no shell-only/skip-marker
@@ -1671,11 +1732,7 @@ function storeBfDoc(
1671
1732
  const priorTime = options.preserveTime ? bfDocCache.get(key)?.time : undefined
1672
1733
  bfDocCache.delete(key)
1673
1734
  bfDocCache.set(key, { time: priorTime ?? clientClockNow(), page })
1674
- while (bfDocCache.size > BF_DOC_CACHE_LIMIT) {
1675
- const oldest = bfDocCache.keys().next().value
1676
- if (oldest === undefined) break
1677
- bfDocCache.delete(oldest)
1678
- }
1735
+ trimOldest(bfDocCache, BF_DOC_CACHE_LIMIT)
1679
1736
  }
1680
1737
 
1681
1738
  function takeBfDoc(key: string): BfDocEntry | null {
@@ -1701,11 +1758,7 @@ function markUrlStaticFresh(key: string, staleTimeMs: number): void {
1701
1758
  if (staleTimeMs <= 0) return
1702
1759
  urlStaticFreshUntil.delete(key)
1703
1760
  urlStaticFreshUntil.set(key, clientClockNow() + staleTimeMs)
1704
- while (urlStaticFreshUntil.size > URL_STATIC_FRESH_LIMIT) {
1705
- const oldest = urlStaticFreshUntil.keys().next().value
1706
- if (oldest === undefined) break
1707
- urlStaticFreshUntil.delete(oldest)
1708
- }
1761
+ trimOldest(urlStaticFreshUntil, URL_STATIC_FRESH_LIMIT)
1709
1762
  }
1710
1763
 
1711
1764
  function urlStaticFresh(key: string): boolean {
@@ -1756,10 +1809,7 @@ function cacheEntryDocument(page: PrefetchedPage) {
1756
1809
  }
1757
1810
  activeBfcacheId = bfcacheId
1758
1811
  entryDocCache.set(id as string, page)
1759
- if (entryDocCache.size > ENTRY_DOC_CACHE_LIMIT) {
1760
- const oldest = entryDocCache.keys().next().value
1761
- if (oldest !== undefined) entryDocCache.delete(oldest)
1762
- }
1812
+ trimOldest(entryDocCache, ENTRY_DOC_CACHE_LIMIT)
1763
1813
  }
1764
1814
 
1765
1815
  // Loading-shell cache: the streamed shell of a route response, carrying its closed
@@ -1887,11 +1937,7 @@ function storeShell(
1887
1937
  ...(staleTimeMs === undefined ? {} : { staleTimeMs }),
1888
1938
  })
1889
1939
  }
1890
- while (shellCache.size > SHELL_CACHE_LIMIT) {
1891
- const oldest = shellCache.keys().next().value
1892
- if (oldest === undefined) break
1893
- shellCache.delete(oldest)
1894
- }
1940
+ trimOldest(shellCache, SHELL_CACHE_LIMIT)
1895
1941
  }
1896
1942
 
1897
1943
  /**
@@ -1928,15 +1974,6 @@ function shellHtmlByPath() {
1928
1974
  return shells
1929
1975
  }
1930
1976
 
1931
- // Segment-prefetch tree cache: a `/_tree` request learns a route's staleTime before
1932
- // any HTML is fetched, so the HTML prefetch's reuse window is right on first visit.
1933
- // Keyed by pathname (Next's PPR segment cache excludes search from the tree key).
1934
- export interface SegmentTree {
1935
- time: number
1936
- staleTimeSeconds: number
1937
- isStatic: boolean
1938
- }
1939
- const segmentTreeCache = new Map<string, SegmentTree>()
1940
1977
  // ---------------------------------------------------------------------------
1941
1978
  // SEGMENT CACHE
1942
1979
  // ---------------------------------------------------------------------------
@@ -1952,55 +1989,6 @@ const SEGMENT_ROUTE_HEADER = 'x-pnext-segment-route'
1952
1989
  // Opt-in: this fetch can consume a late metadata tail written after `</html>`.
1953
1990
  const LATE_METADATA_HEADER = 'x-pnext-late-metadata'
1954
1991
 
1955
- /**
1956
- * Fetch the `/_tree` segment-prefetch payload for a route and cache its
1957
- * staleTime. Best-effort: a server that doesn't answer segment prefetches (or a
1958
- * dev server) simply leaves the tree cache empty and the HTML prefetch falls
1959
- * back to the mode-derived window. Returns the parsed staleTime (seconds) or
1960
- * undefined.
1961
- */
1962
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1963
- async function segmentTreePrefetch(url: URL): Promise<number | undefined> {
1964
- const key = segmentCachePathname(url)
1965
- const cached = segmentTreeCache.get(key)
1966
- if (cached && clientClockNow() - cached.time < cached.staleTimeSeconds * 1000) {
1967
- touchCacheEntry(segmentTreeCache, key, cached)
1968
- return cached.staleTimeSeconds
1969
- }
1970
- try {
1971
- const init: RequestInit & { priority?: 'auto' | 'high' | 'low' } = {
1972
- headers: {
1973
- rsc: '1',
1974
- 'next-router-prefetch': '1',
1975
- [SEGMENT_PREFETCH_HEADER]: TREE_SEGMENT_PATH,
1976
- },
1977
- credentials: 'same-origin',
1978
- priority: 'low',
1979
- }
1980
- const response = await fetch(withRscQuery(url.href), init)
1981
- if (!response.ok) return undefined
1982
- const type = response.headers.get('content-type') ?? ''
1983
- if (!type.includes('text/x-component')) return undefined
1984
- const text = await response.text()
1985
- // Requests with the `_rsc` CDN key use Flight's `0:<json>` framing. Keep
1986
- // accepting unframed JSON for direct compat responders.
1987
- const payload = JSON.parse(text.startsWith('0:') ? text.slice(2) : text) as {
1988
- staleTime?: number
1989
- isStatic?: boolean
1990
- }
1991
- const staleTimeSeconds = typeof payload.staleTime === 'number' ? payload.staleTime : undefined
1992
- if (staleTimeSeconds === undefined) return undefined
1993
- segmentTreeCache.set(key, {
1994
- time: clientClockNow(),
1995
- staleTimeSeconds,
1996
- isStatic: Boolean(payload.isStatic),
1997
- })
1998
- return staleTimeSeconds
1999
- } catch {
2000
- return undefined
2001
- }
2002
- }
2003
-
2004
1992
  // Per-segment DOCUMENT cache: the HTML a `/_page`-style segment response
2005
1993
  // carries, keyed by segment path and reusable across search params.
2006
1994
 
@@ -2146,12 +2134,6 @@ function stripRscQuery(href: string): string {
2146
2134
  let revalidationPrefetchGeneration = 0
2147
2135
  let revalidationPrefetchBlocked = false
2148
2136
 
2149
- // Segment body cache. Beyond `/_tree`, the client fetches the route's body segment (served
2150
- // as text/x-component) into an LRU keyed by PATHNAME - search params are excluded from the
2151
- // PPR segment key, so it is reusable across `?a`/`?b`. Entries expire on their own
2152
- // x-nextjs-stale-time window, and a server-action revalidate evicts them.
2153
-
2154
- const ROUTE_SEGMENT_PATH = '/'
2155
2137
  const SEGMENT_CACHE_LIMIT = 32
2156
2138
 
2157
2139
  // Per-segment cache policy seam (compat: compat/client/segment-cache.ts). The caches above
@@ -2192,84 +2174,6 @@ function prefetchBodySegmentPath(href: string): string {
2192
2174
  : INDEX_SEGMENT_PATH
2193
2175
  }
2194
2176
 
2195
- export interface SegmentBodyEntry {
2196
- time: number
2197
- staleTimeMs: number
2198
- body: string
2199
- /** A fully-static route body is a complete document safe to stitch on nav. */
2200
- complete: boolean
2201
- }
2202
- const segmentBodyCache = new Map<string, SegmentBodyEntry>()
2203
-
2204
- /** Fetch and cache the whole-route body segment for `url`. Best-effort. */
2205
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
2206
- async function segmentBodyPrefetch(url: URL): Promise<boolean> {
2207
- const key = segmentCachePathname(url)
2208
- const cached = segmentBodyCache.get(key)
2209
- if (cached && clientClockNow() - cached.time < cached.staleTimeMs) {
2210
- touchCacheEntry(segmentBodyCache, key, cached)
2211
- return true
2212
- }
2213
- try {
2214
- const response = await fetch(withRscQuery(url.href), {
2215
- headers: {
2216
- rsc: '1',
2217
- 'next-router-prefetch': '1',
2218
- [SEGMENT_PREFETCH_HEADER]: ROUTE_SEGMENT_PATH,
2219
- },
2220
- credentials: 'same-origin',
2221
- priority: 'low',
2222
- })
2223
- if (!response.ok) return false
2224
- const type = response.headers.get('content-type') ?? ''
2225
- if (!type.includes('text/x-component')) return false
2226
- const body = await response.text()
2227
- const staleHeader = response.headers.get('x-nextjs-stale-time')
2228
- const staleSeconds = staleHeader ? Number(staleHeader) : 30
2229
- // A body served without x-nextjs-postponed is a complete (non-PPR) route
2230
- // body: safe to commit on navigation without a second fetch. A postponed
2231
- // (PPR shell) body is only the static shell, so it is cached for warmth but
2232
- // not stitched as the whole page.
2233
- const complete = response.headers.get('x-nextjs-postponed') !== '1'
2234
- setSegmentBody(
2235
- key,
2236
- body,
2237
- Number.isFinite(staleSeconds) ? staleSeconds * 1000 : 30_000,
2238
- complete,
2239
- )
2240
- return true
2241
- } catch {
2242
- return false
2243
- }
2244
- }
2245
-
2246
- function setSegmentBody(key: string, body: string, staleTimeMs: number, complete: boolean): void {
2247
- segmentBodyCache.set(key, { time: clientClockNow(), staleTimeMs, body, complete })
2248
- // LRU: evict the oldest entries beyond the limit.
2249
- while (segmentBodyCache.size > SEGMENT_CACHE_LIMIT) {
2250
- const oldest = segmentBodyCache.keys().next().value
2251
- if (oldest === undefined) break
2252
- segmentBodyCache.delete(oldest)
2253
- }
2254
- }
2255
-
2256
- /**
2257
- * A cached, COMPLETE body segment for `pathname` within its staleTime window (safe to stitch
2258
- * as the navigation page), or null. A PPR (postponed) shell is never returned here - it is a
2259
- * static shell, not the whole page.
2260
- */
2261
- function takeSegmentBody(pathname: string): string | null {
2262
- const key = segmentCachePathname(pathname)
2263
- const entry = segmentBodyCache.get(key)
2264
- if (!entry) return null
2265
- if (clientClockNow() - entry.time >= entry.staleTimeMs) {
2266
- segmentBodyCache.delete(key)
2267
- return null
2268
- }
2269
- touchCacheEntry(segmentBodyCache, key, entry)
2270
- return entry.complete ? entry.body : null
2271
- }
2272
-
2273
2177
  function segmentCachePathname(url: URL | string): string {
2274
2178
  const pathname = typeof url === 'string' ? url : url.pathname
2275
2179
  // URL.pathname retains a literal %2F, which is the route-cache identity; do
@@ -2285,14 +2189,11 @@ function segmentCachePathname(url: URL | string): string {
2285
2189
 
2286
2190
  /**
2287
2191
  * Evict segment-cache entries on revalidation, so the next prefetch re-fetches fresh data.
2288
- * Clears both the tree and body caches: Next evicts by tag, but pnext has no client-side tag
2289
- * map, so a full clear is the honest superset.
2192
+ * Next evicts by tag, but pnext has no client-side tag map, so a full clear is the honest superset.
2290
2193
  */
2291
2194
  function evictSegmentCache(options: { pageSegmentsOnly?: boolean } = {}): void {
2292
- segmentTreeCache.clear()
2293
2195
  learnedRouteTrees.clear()
2294
2196
  routeTreesEvicted.clear()
2295
- segmentBodyCache.clear()
2296
2197
  segmentDocumentCache.clear()
2297
2198
  // A same-URL navigation refreshes the PAGE segments only — the shared layout
2298
2199
  // and head segments of the current tree stay cached (Next's refresh
@@ -2316,10 +2217,8 @@ export function evictClientRouterCache(options: { rearmVisiblePrefetches?: boole
2316
2217
  bumpRouterCacheEpoch()
2317
2218
  revalidationPrefetchGeneration += 1
2318
2219
  revalidationPrefetchBlocked = true
2319
- segmentTreeCache.clear()
2320
2220
  learnedRouteTrees.clear()
2321
2221
  routeTreesEvicted.clear()
2322
- segmentBodyCache.clear()
2323
2222
  segmentDocumentCache.clear()
2324
2223
  segmentCachePolicy?.clear()
2325
2224
  const invalidated = Array.from(prefetchCache.values())
@@ -2387,12 +2286,6 @@ function notifyPrefetchInvalidation(entry: PrefetchEntry, afterRevalidation = fa
2387
2286
  }
2388
2287
  }
2389
2288
 
2390
- /** Non-consuming freshness check for the segment body cache. */
2391
- function peekSegmentBody(pathname: string): boolean {
2392
- const entry = segmentBodyCache.get(segmentCachePathname(pathname))
2393
- return Boolean(entry && clientClockNow() - entry.time < entry.staleTimeMs && entry.complete)
2394
- }
2395
-
2396
2289
  /** Drop the cached document + shell for a refreshed/revalidated URL. */
2397
2290
  function evictNavigationCache(key: string, pathname: string): void {
2398
2291
  const entries = [
@@ -2428,6 +2321,8 @@ export interface PrefetchFetchTask {
2428
2321
  phase: number
2429
2322
  cancelled: boolean
2430
2323
  element?: Element
2324
+ /** A navigation awaits it: every phase starts at once, outside the limits. */
2325
+ navigation?: boolean
2431
2326
  }
2432
2327
 
2433
2328
  let prefetchSortIdCounter = 0
@@ -2544,6 +2439,10 @@ function pumpPrefetchQueue(): void {
2544
2439
  if (waiter.task.cancelled) {
2545
2440
  prefetchSlotWaiters.splice(index, 1)
2546
2441
  waiter.resolve(false)
2442
+ } else if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && waiter.task.navigation) {
2443
+ prefetchSlotWaiters.splice(index, 1)
2444
+ inFlightPrefetchRequests++
2445
+ waiter.resolve(true)
2547
2446
  }
2548
2447
  }
2549
2448
  prefetchSlotWaiters.sort(
@@ -2609,6 +2508,8 @@ export interface PrefetchEntry {
2609
2508
  * stay bound to the exact nav state they rendered against (stateKey).
2610
2509
  */
2611
2510
  originAgnostic?: boolean
2511
+ /** A settled skip document serves any origin that still shows these layouts (`segmentKey`). */
2512
+ keptLayouts?: string[]
2612
2513
  /**
2613
2514
  * The entry's document is an interception HOST render. Such an entry is bound to its
2614
2515
  * exact host nav state - it never serves via the origin-agnostic (slot-only) match,
@@ -2619,7 +2520,7 @@ export interface PrefetchEntry {
2619
2520
  slotsKey: string
2620
2521
  onInvalidate: (() => void)[]
2621
2522
  /** A core prefetch's streamed shell, painted by a click that attaches before the body lands. */
2622
- shell?: { html?: string; listeners: Set<(html: string) => void> }
2523
+ shell?: { html?: string; listeners: Set<(html: string) => void>; task: PrefetchFetchTask }
2623
2524
  }
2624
2525
 
2625
2526
  const prefetchCache = new Map<string, PrefetchEntry>()
@@ -2716,8 +2617,7 @@ function matchesLearnedRoute(routes: Set<string>, pathname: string): boolean {
2716
2617
 
2717
2618
  // Learned route trees. Next's segment cache keys the ROUTE TREE by ROUTE, not by URL,
2718
2619
  // so once `/photo/1` has taught the client the tree of `/photo/:id`, revealing
2719
- // `/photo/2` issues no `/_tree` request. `segmentTreeCache` above is keyed by exact
2720
- // pathname and misses every sibling, so this map closes that gap. Entries are learned
2620
+ // `/photo/2` issues no `/_tree` request. Entries are learned
2721
2621
  // from both prefetch tree and navigation responses, and resolve to their route LAZILY:
2722
2622
  // a baked tree response carries no `route` field, but the segment cache learns the
2723
2623
  // pathname->route mapping from the payload that follows.
@@ -2780,11 +2680,7 @@ function learnRouteTree(pathname: string, search: string, route?: string, rewrit
2780
2680
  ...(route ? { route } : {}),
2781
2681
  ...(rewritten ? { rewritten: true } : {}),
2782
2682
  })
2783
- while (learnedRouteTrees.size > SEGMENT_CACHE_LIMIT) {
2784
- const oldest = learnedRouteTrees.keys().next().value
2785
- if (oldest === undefined) break
2786
- learnedRouteTrees.delete(oldest)
2787
- }
2683
+ trimOldest(learnedRouteTrees, SEGMENT_CACHE_LIMIT)
2788
2684
  }
2789
2685
 
2790
2686
  /**
@@ -2806,11 +2702,7 @@ function forgetLearnedRouteTree(urlKey: string): void {
2806
2702
  // URL learns a tree of its own again.
2807
2703
  routeTreesEvicted.delete(key)
2808
2704
  routeTreesEvicted.add(key)
2809
- while (routeTreesEvicted.size > SEGMENT_CACHE_LIMIT) {
2810
- const oldest = routeTreesEvicted.values().next().value
2811
- if (oldest === undefined) break
2812
- routeTreesEvicted.delete(oldest)
2813
- }
2705
+ trimOldest(routeTreesEvicted, SEGMENT_CACHE_LIMIT)
2814
2706
  }
2815
2707
 
2816
2708
  /**
@@ -3051,7 +2943,7 @@ export function prefetchRoute(
3051
2943
  const task = createPrefetchTask(cacheKey, options)
3052
2944
  // A core prefetch is the destination document itself, so its shell can paint an attached click.
3053
2945
  const shell: PrefetchEntry['shell'] =
3054
- typeof __PNEXT_NEXT_ROUTER__ === 'undefined' ? { listeners: new Set() } : undefined
2946
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' ? { listeners: new Set(), task } : undefined
3055
2947
  const page = fetchPage(url.href, {
3056
2948
  prefetch: full ? 'full' : 'auto',
3057
2949
  navState: prefetchState,
@@ -3105,6 +2997,8 @@ export function prefetchRoute(
3105
2997
  entry.intercepted = pageIsHostRender(fetched)
3106
2998
  entry.originAgnostic =
3107
2999
  fetched.shellOnly !== true && !fetched.html.includes(SKIP_MARKER) && !entry.intercepted
3000
+ if (!entry.originAgnostic && !fetched.shellOnly && !entry.intercepted)
3001
+ entry.keptLayouts = skippedLayoutKeys(fetched.html)
3108
3002
  entry.bytes = fetched.html.length
3109
3003
  }
3110
3004
  // This URL's STATIC data is now fresh for the response's window: a
@@ -3528,9 +3422,7 @@ async function fetchPage(
3528
3422
  init,
3529
3423
  `${rscVariant}:/_index`,
3530
3424
  )
3531
- segmentPayload =
3532
- indexResponse.headers.get('content-type')?.includes('text/x-component') === true &&
3533
- indexResponse.headers.get('x-nextjs-postponed') === '2'
3425
+ segmentPayload = isSegmentPayload(indexResponse)
3534
3426
  if (!segmentPayload && !indexResponse.headers.get('content-type')) {
3535
3427
  strippedBody = await indexResponse.text()
3536
3428
  }
@@ -3565,9 +3457,7 @@ async function fetchPage(
3565
3457
  init,
3566
3458
  `${rscVariant}:${bodySegment}`,
3567
3459
  )
3568
- segmentPayload =
3569
- indexResponse.headers.get('content-type')?.includes('text/x-component') === true &&
3570
- indexResponse.headers.get('x-nextjs-postponed') === '2'
3460
+ segmentPayload = isSegmentPayload(indexResponse)
3571
3461
  // Header-stripping intermediary: classify the frame by SHAPE, the way the
3572
3462
  // tree phase classifies its own payload.
3573
3463
  if (!segmentPayload && !indexResponse.headers.get('content-type')) {
@@ -3585,9 +3475,7 @@ async function fetchPage(
3585
3475
  init,
3586
3476
  `${rscVariant}:${TREE_SEGMENT_PATH}`,
3587
3477
  )
3588
- segmentPayload =
3589
- treeResponse.headers.get('content-type')?.includes('text/x-component') === true &&
3590
- treeResponse.headers.get('x-nextjs-postponed') === '2'
3478
+ segmentPayload = isSegmentPayload(treeResponse)
3591
3479
  let treePayload: ReturnType<typeof parseSegmentTreePayload> = null
3592
3480
  if (!segmentPayload && !treeResponse.headers.get('content-type')) {
3593
3481
  const text = await treeResponse.text()
@@ -3727,8 +3615,8 @@ async function fetchPage(
3727
3615
  ? await readStreamedBody(
3728
3616
  response,
3729
3617
  options.onShell,
3730
- // A prefetch read can end mid-shell; its shell counts once the document's tail scripts land.
3731
- typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && options.prefetch
3618
+ // A read can end mid-shell; the shell counts once the document's tail scripts land.
3619
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined'
3732
3620
  ? shell => shell.includes('id="__PNEXT_NAV_STATE__"') && /<\/script>\s*$/.test(shell)
3733
3621
  : undefined,
3734
3622
  )
@@ -3865,36 +3753,24 @@ async function fetchPageFrameNavigation(
3865
3753
  if (!(response.headers.get('content-type') ?? '').includes('text/x-component')) return null
3866
3754
  const responseBody = await response.text()
3867
3755
  const frame = segmentDocumentHtml(responseBody, PAGE_SEGMENT_REQUEST_PATH)
3868
- if (frame === null) {
3869
- // The server could not frame this route (parallel slots, interception) and replied with
3870
- // the WHOLE DOCUMENT. Commit it: falling through to `fetchPage` would refetch the same
3871
- // URL and put two identical navigation responses on the wire for one navigation.
3872
- if (!responseBody.trimStart().startsWith('{') && !responseBody.startsWith('0:')) {
3873
- const wholeStaleHeader = response.headers.get('x-nextjs-stale-time')
3874
- const wholeStaleSeconds = wholeStaleHeader === null ? undefined : Number(wholeStaleHeader)
3875
- return {
3876
- html: responseBody,
3877
- finalUrl: url.href,
3878
- ok: true,
3879
- ...(wholeStaleSeconds !== undefined && Number.isFinite(wholeStaleSeconds)
3880
- ? { staleTimeSeconds: wholeStaleSeconds }
3881
- : {}),
3882
- }
3883
- }
3884
- return null
3885
- }
3886
- const html = policy.composePageFrame({ ...lookup, pageHtml: frame })
3887
- if (html === null) return null
3888
3756
  const staleTimeHeader = response.headers.get('x-nextjs-stale-time')
3889
3757
  const staleTimeSeconds = staleTimeHeader === null ? undefined : Number(staleTimeHeader)
3890
- return {
3758
+ const page = (html: string): PrefetchedPage => ({
3891
3759
  html,
3892
3760
  finalUrl: url.href,
3893
3761
  ok: true,
3894
- ...(staleTimeSeconds !== undefined && Number.isFinite(staleTimeSeconds)
3895
- ? { staleTimeSeconds }
3896
- : {}),
3762
+ ...(Number.isFinite(staleTimeSeconds) ? { staleTimeSeconds } : {}),
3763
+ })
3764
+ if (frame === null) {
3765
+ // The server could not frame this route (parallel slots, interception) and replied with
3766
+ // the WHOLE DOCUMENT. Commit it: falling through to `fetchPage` would refetch the same
3767
+ // URL and put two identical navigation responses on the wire for one navigation.
3768
+ return !responseBody.trimStart().startsWith('{') && !responseBody.startsWith('0:')
3769
+ ? page(responseBody)
3770
+ : null
3897
3771
  }
3772
+ const html = policy.composePageFrame({ ...lookup, pageHtml: frame })
3773
+ return html === null ? null : page(html)
3898
3774
  }
3899
3775
 
3900
3776
  /** Feed one segment-prefetch response into the registered per-segment policy. */
@@ -4030,8 +3906,6 @@ function parseSegmentTreePayload(body: string): {
4030
3906
  staleTime?: number
4031
3907
  isStatic: boolean
4032
3908
  route?: string
4033
- headOutlined?: boolean
4034
- headFirst?: boolean
4035
3909
  } | null {
4036
3910
  const json = body.startsWith('0:') ? body.slice(2) : body
4037
3911
  if (!json.startsWith('{')) return null
@@ -4041,8 +3915,6 @@ function parseSegmentTreePayload(body: string): {
4041
3915
  staleTime?: unknown
4042
3916
  isStatic?: unknown
4043
3917
  route?: unknown
4044
- headOutlined?: unknown
4045
- headFirst?: unknown
4046
3918
  }
4047
3919
  if (!payload.tree || typeof payload.tree !== 'object') return null
4048
3920
  return {
@@ -4052,14 +3924,20 @@ function parseSegmentTreePayload(body: string): {
4052
3924
  // stamped one (a BAKED tree carries none — the router then falls back to
4053
3925
  // the segment cache's own pathname->route map).
4054
3926
  ...(typeof payload.route === 'string' ? { route: payload.route } : {}),
4055
- ...(payload.headOutlined === true ? { headOutlined: true } : {}),
4056
- ...(payload.headFirst === true ? { headFirst: true } : {}),
4057
3927
  }
4058
3928
  } catch {
4059
3929
  return null
4060
3930
  }
4061
3931
  }
4062
3932
 
3933
+ /** A per-segment prefetch payload (not a document): Next's RSC content type with the segment postpone marker. */
3934
+ function isSegmentPayload(response: Response): boolean {
3935
+ return (
3936
+ response.headers.get('content-type')?.includes('text/x-component') === true &&
3937
+ response.headers.get('x-nextjs-postponed') === '2'
3938
+ )
3939
+ }
3940
+
4063
3941
  /** Insert an outlined head fragment (`<title>…`) back before </head>. */
4064
3942
  function mergeOutlinedHead(html: string, headFragment: string): string {
4065
3943
  const at = html.indexOf('</head>')
@@ -4100,6 +3978,8 @@ export function showLoadingShell(
4100
3978
  allowWithoutBoundary = false,
4101
3979
  /** The shell came from this navigation's live response at its commit boundary. */
4102
3980
  allowResponseSuspense = false,
3981
+ /** Takes the paint's still-loading stylesheets instead of painting without them. */
3982
+ hold?: SheetHold,
4103
3983
  ) {
4104
3984
  if (sequence !== navigationSequence) return false
4105
3985
  if (typeof DOMParser === 'undefined') return false
@@ -4152,6 +4032,34 @@ export function showLoadingShell(
4152
4032
  (container.id ? doc.getElementById(container.id) : null) ??
4153
4033
  doc.querySelector('[data-pnext-root]') ??
4154
4034
  (container === document.body ? doc.body : null)
4035
+ // A boundary owned above the live slot's layout replaces that diverging layout, not its slot;
4036
+ // a shell cut above its own page slot paints the boundary (or its new layout) into the slot.
4037
+ const layouts =
4038
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4039
+ markerRange &&
4040
+ loadingBoundary &&
4041
+ divergingLayout(markerRange[0], boundary!)
4042
+ const [diverging, painted] =
4043
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && layouts && (layouts[0] || !incomingSlot)
4044
+ ? layouts
4045
+ : []
4046
+ // A CLIENT root layout owns the paint target: the page container sits inside a mounted
4047
+ // island's Preact tree, which still points at the DOM nodes about to be replaced.
4048
+ // Overwriting them behind Preact's back desynchronizes the live tree, so every later
4049
+ // render patches detached nodes. Re-render the island with the shell as its incoming
4050
+ // children instead - the same graft the final commit uses.
4051
+ const liveOwner = liveIslandOwner(container)
4052
+ const incomingOwner = liveOwner && incomingIslandFor(doc, liveOwner)
4053
+ // The app root holds the live slot's own layouts: never paint it into that slot.
4054
+ if (
4055
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4056
+ markerRange &&
4057
+ boundary &&
4058
+ !incomingSlot &&
4059
+ !painted &&
4060
+ !incomingOwner
4061
+ )
4062
+ return false
4155
4063
  const sourceNodes = incomingSlot
4156
4064
  ? incomingSlot[2]
4157
4065
  : incomingTarget
@@ -4166,31 +4074,51 @@ export function showLoadingShell(
4166
4074
  // nothing this paint could put on screen.
4167
4075
  null
4168
4076
  if (!sourceNodes) return false
4077
+ // Past every bailout: this paint puts the destination on screen and commits the
4078
+ // navigation, so its sheets go in with it rather than waiting for the payload.
4079
+ const sheets = installStylesheets(doc)
4080
+ if (hold && sheets.length) {
4081
+ hold(sheets)
4082
+ return false
4083
+ }
4169
4084
  // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
4170
4085
  // bar the instant this returns true), so the window route state must reflect the
4171
4086
  // DESTINATION before any island reads useParams - otherwise usePathname and useParams
4172
4087
  // diverge and a history entry captures the new URL with the OLD params.
4173
4088
  const shellRoute = predictedRoute ?? documentRouteState(doc)
4174
4089
  if (shellRoute) window.__PNEXT_ROUTE__ = shellRoute
4175
- // Past every bailout: this paint puts the destination on screen and commits the
4176
- // navigation, so its sheets go in with it rather than waiting for the payload.
4177
- void installStylesheets(doc)
4178
- for (const node of sourceNodes) fragment.append(document.importNode(node, true))
4090
+ // A bare boundary fills the shared layout's page slot; a new layout holds that slot itself.
4091
+ if (painted && painted !== boundary) {
4092
+ boundary!.before(doc.createComment('pnext-page:'))
4093
+ boundary!.after(doc.createComment('/pnext-page'))
4094
+ }
4095
+ for (const node of painted === boundary
4096
+ ? (inline?.nodes ?? [marker!])
4097
+ : painted
4098
+ ? [painted]
4099
+ : sourceNodes)
4100
+ fragment.append(document.importNode(node, true))
4179
4101
  // A STATIC STAGE paints committed content, so its fallbacks land bare — the
4180
4102
  // wrappers are stripped from the COPY (the source doc keeps them, so the
4181
4103
  // boundary lookups above stay valid).
4182
4104
  if (allowWithoutBoundary) unwrapSuspenseFallbacks(fragment)
4183
- // A CLIENT root layout owns the paint target: the page container sits inside a mounted
4184
- // island's Preact tree, which still points at the DOM nodes about to be replaced.
4185
- // Overwriting them behind Preact's back desynchronizes the live tree, so every later
4186
- // render patches detached nodes. Re-render the island with the shell as its incoming
4187
- // children instead - the same graft the final commit uses.
4188
- const liveOwner = liveIslandOwner(container)
4189
- const incomingOwner = liveOwner && incomingIslandFor(doc, liveOwner)
4105
+ for (const layout of fragment.querySelectorAll('pnext-layout[data-pnext-segment]'))
4106
+ layout.setAttribute(SHELL_LAYOUT_ATTRIBUTE, '')
4190
4107
  if (liveOwner && incomingOwner) liveOwner.__pnextIncoming = incomingOwner
4191
- else if (markerRange) {
4108
+ else if (diverging) {
4109
+ diverging.replaceWith(
4110
+ ...(painted === boundary
4111
+ ? [document.createComment('pnext-page:'), fragment, document.createComment('/pnext-page')]
4112
+ : [fragment]),
4113
+ )
4114
+ } else if (markerRange) {
4192
4115
  for (const node of markerRange[2]) node.remove()
4193
4116
  markerRange[0].after(fragment)
4117
+ // The new layout holds the page slot itself.
4118
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && painted && painted !== boundary) {
4119
+ markerRange[0].remove()
4120
+ markerRange[1]?.remove()
4121
+ }
4194
4122
  } else container.replaceChildren(fragment)
4195
4123
  // Any painted shell can carry islands (a loading fallback that calls
4196
4124
  // useOffline(), say), and an unmounted island keeps its SSR value forever.
@@ -4265,6 +4193,7 @@ export function commitStaticStage(
4265
4193
  * destination content beside the holes or an explicit loading.js boundary.
4266
4194
  */
4267
4195
  postponedShell = false,
4196
+ hold?: SheetHold,
4268
4197
  ): boolean {
4269
4198
  if (sequence !== navigationSequence) return false
4270
4199
  // cacheComponents' postponed stage is a real segment-cache commit, including
@@ -4272,8 +4201,8 @@ export function commitStaticStage(
4272
4201
  // Ordinary apps retain the stricter rule that prevented their root splash
4273
4202
  // from painting merely because a shell arrived.
4274
4203
  if (!staticStageIsRealContent(html, postponedShell && activityBfcacheEnabled())) return false
4275
- if (paintStaticStageSubtree(html)) return true
4276
- return showLoadingShell(html, sequence, target, undefined, true)
4204
+ if (paintStaticStageSubtree(html, hold)) return true
4205
+ return showLoadingShell(html, sequence, target, undefined, true, false, hold)
4277
4206
  }
4278
4207
 
4279
4208
  /**
@@ -4371,7 +4300,7 @@ function unwrapSuspenseFallbacks(root: ParentNode): void {
4371
4300
  * returning false when a page-slot-only paint is enough (the caller then falls back to
4372
4301
  * `showLoadingShell`). Exported for the DOM-level unit test.
4373
4302
  */
4374
- export function paintStaticStageSubtree(html: string): boolean {
4303
+ export function paintStaticStageSubtree(html: string, hold?: SheetHold): boolean {
4375
4304
  if (typeof DOMParser === 'undefined') return false
4376
4305
  const doc = new DOMParser().parseFromString(html, 'text/html')
4377
4306
  if (!isPNextDocument(doc)) return false
@@ -4383,7 +4312,11 @@ export function paintStaticStageSubtree(html: string): boolean {
4383
4312
  if (!staticStageFrameGrows(doc)) return false
4384
4313
  // This paint COMMITS the navigation, so the destination's sheets belong on the document
4385
4314
  // now; the commit below re-reads them from its own doc and waits out these loads.
4386
- void installStylesheets(doc)
4315
+ const sheets = installStylesheets(doc)
4316
+ if (hold && sheets.length) {
4317
+ hold(sheets)
4318
+ return false
4319
+ }
4387
4320
  unwrapSuspenseFallbacks(doc)
4388
4321
  // A painted static stage IS a committed navigation (see showLoadingShell):
4389
4322
  // the window route state must describe the destination before any island
@@ -4521,10 +4454,8 @@ function loadingBoundaryChanges(marker: Element | null, target: URL): boolean {
4521
4454
  // segment reuse or a fresh fetch — and recording what the answer taught us.
4522
4455
 
4523
4456
  /**
4524
- * How long a navigation may wait on a prefetch that has not landed yet. pnext attaches to
4525
- * in-flight prefetches to avoid a duplicate request, but must never wait forever - a
4526
- * partial prefetch of a route with an unresolvable Suspense boundary never responds, and
4527
- * blocking on it would leave the click with no loading shell and no history push.
4457
+ * How long a compat navigation may wait on a prefetch that has not landed yet. A partial
4458
+ * prefetch of a route with an unresolvable Suspense boundary never responds.
4528
4459
  */
4529
4460
  const UNSETTLED_PREFETCH_WAIT_MS = 300
4530
4461
 
@@ -4533,27 +4464,22 @@ function unsettledPrefetchDeadline(): Promise<null> {
4533
4464
  return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
4534
4465
  }
4535
4466
 
4536
- /**
4537
- * The in-flight prefetch's document, or `null` once it has waited long enough. A streamed shell
4538
- * that paints this navigation first commits it to that prefetch, so it waits for the body.
4539
- */
4540
- function attachToPrefetch(
4467
+ /** Awaits the in-flight prefetch's document, painting its streamed shell when one lands first. */
4468
+ async function attachToPrefetch(
4541
4469
  entry: PrefetchEntry,
4542
4470
  onShell?: (shellHtml: string) => boolean,
4543
4471
  ): Promise<PrefetchedPage | null> {
4544
- return new Promise(resolve => {
4545
- const shell = entry.shell
4546
- const done = (page: PrefetchedPage | Promise<PrefetchedPage | null> | null) => {
4547
- clearTimeout(timer)
4548
- shell?.listeners.delete(paint)
4549
- resolve(page)
4550
- }
4551
- const paint = (html: string) => onShell?.(html) && done(entry.page)
4552
- const timer = setTimeout(done, UNSETTLED_PREFETCH_WAIT_MS, null)
4553
- void entry.page.then(done)
4554
- if (shell?.html) paint(shell.html)
4555
- else shell?.listeners.add(paint)
4556
- })
4472
+ // Every unsettled core entry is a prefetchRoute entry, so it has a shell.
4473
+ const shell = entry.shell!
4474
+ // The navigation owns this prefetch now: a queued phase starts at once.
4475
+ shell.task.navigation = true
4476
+ pumpPrefetchQueue()
4477
+ const paint = (html: string) => onShell?.(html) && shell.listeners.delete(paint)
4478
+ if (shell.html) paint(shell.html)
4479
+ else shell.listeners.add(paint)
4480
+ const page = await entry.page
4481
+ shell.listeners.delete(paint)
4482
+ return page
4557
4483
  }
4558
4484
 
4559
4485
  // Core reuses a kept copy of a dynamic page this long; past it only the copy's frame paints.
@@ -4633,7 +4559,7 @@ async function pageForNavigation(
4633
4559
  // attach to it on its own settle signal (cancel/error resolve null), never a wall
4634
4560
  // clock - a slow machine must not make the router duplicate the request.
4635
4561
  const page =
4636
- cached.settled || cached.full
4562
+ cached.settled || (cached.full && !cached.shell)
4637
4563
  ? await cached.page
4638
4564
  : typeof __PNEXT_NEXT_ROUTER__ === 'undefined'
4639
4565
  ? await attachToPrefetch(cached, onShell)
@@ -4684,10 +4610,6 @@ async function pageForNavigation(
4684
4610
  if (segmentHit?.networkFree && !slotStateSensitive(navState, segmentHit.html)) {
4685
4611
  return { html: segmentHit.html, finalUrl: url.href, ok: true }
4686
4612
  }
4687
- const body = takeSegmentBody(url.pathname)
4688
- if (body && !slotStateSensitive(navState, body)) {
4689
- return { html: body, finalUrl: url.href, ok: true }
4690
- }
4691
4613
  // No entry for this exact URL: a fresh fully-prerendered document for the
4692
4614
  // same pathname (different search params) still commits without a fetch.
4693
4615
  const shared = await samePathnamePrerenderedPage(url, now, departureNavState)
@@ -4793,11 +4715,17 @@ function seedNavigationEntry(
4793
4715
  // resolve network-free and prefetch nothing. The shell/segment caches below are still
4794
4716
  // seeded, so the navigation itself stays instant. A DYNAMIC render is seedable, but only
4795
4717
  // when the app opted into a non-zero `staleTimes.dynamic` window (Next's default is 0).
4718
+ const skipped = page.html.includes(SKIP_MARKER)
4719
+ // Core keeps a skip-marker visit too, bound to the layouts it grafts onto like a prefetch.
4720
+ const keptLayouts =
4721
+ skipped && typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !prefetchStaleTimePolicy
4722
+ ? skippedLayoutKeys(page.html)
4723
+ : undefined
4796
4724
  const dynamicSeedAllowed =
4797
4725
  page.shellOnly !== true &&
4798
4726
  staleTimeMs > 0 &&
4799
4727
  prefetchStaleTimeMs({}) > 0 &&
4800
- !page.html.includes(SKIP_MARKER)
4728
+ (!skipped || keptLayouts !== undefined)
4801
4729
  // `prefetch = 'allow-runtime'`: a visited document of such a route is request data with
4802
4730
  // the dynamic holes already filled in. Keeping it reusable would commit a later navigation
4803
4731
  // network-free off content the route promises to re-sample. Only its runtime-prefetch
@@ -4824,8 +4752,8 @@ function seedNavigationEntry(
4824
4752
  intercepted: pageIsHostRender(page),
4825
4753
  // A hard load (or complete DIRECT nav response) seeds a document any origin
4826
4754
  // can reuse — Next seeds its router cache the same way, origin-independent.
4827
- originAgnostic:
4828
- page.shellOnly !== true && !page.html.includes(SKIP_MARKER) && !pageIsHostRender(page),
4755
+ originAgnostic: page.shellOnly !== true && !skipped && !pageIsHostRender(page),
4756
+ keptLayouts,
4829
4757
  settled: true,
4830
4758
  onInvalidate: [],
4831
4759
  })
@@ -4850,11 +4778,18 @@ function seedNavigationEntry(
4850
4778
  */
4851
4779
  function preHydrationShell(): string | undefined {
4852
4780
  const stashed = (window as { __PNEXT_SHELL_HTML__?: string }).__PNEXT_SHELL_HTML__
4781
+ if (!stashed) return
4782
+ // The stash precedes the route-state and entry scripts; carry them like a prefetched stage does.
4783
+ const tail = [
4784
+ document.getElementById('__PNEXT_NAV_STATE__'),
4785
+ [...document.scripts].find(script => script.textContent?.startsWith(ROUTE_STATE_PREFIX)),
4786
+ document.body.querySelector('script[type="module"][src]'),
4787
+ ].map(script => script?.outerHTML ?? '')
4853
4788
  // If a promotion script won the capture race, one or more dynamic continuations already sit in
4854
4789
  // the markup. Those carry the loaded URL's resolved content, so anything replaying this stage -
4855
4790
  // a sibling param reusing it across an empty vary set - would paint that URL's data. Drop any
4856
4791
  // carriers; the document-bootstrap zero-stream case simply has none.
4857
- return stashed && stripStreamChunks(stashed)
4792
+ return stripStreamChunks(stashed) + tail.join('')
4858
4793
  }
4859
4794
 
4860
4795
  /** `html` without the hidden `<div data-pnext-stream>` carriers of its streamed continuations. */
@@ -4958,6 +4893,16 @@ function recordNavigationSegment(
4958
4893
 
4959
4894
  /** Server-skipped shared-layout marker — such a document is origin-bound. */
4960
4895
  const SKIP_MARKER = 'data-pnext-skip'
4896
+ const SKIPPED_LAYOUT =
4897
+ /data-pnext-segment="[^"]*" data-pnext-skip(?:="")?(?: data-pnext-scope="[^"]*")?/g
4898
+
4899
+ /** The segment keys of the live layouts a skip-marker document grafts onto. */
4900
+ function skippedLayoutKeys(html: string): string[] {
4901
+ // Parsed like the live layouts, so escaped attribute values key the same.
4902
+ const markers = document.createElement('template')
4903
+ markers.innerHTML = (html.match(SKIPPED_LAYOUT) ?? []).map(m => `<p ${m}>`).join('')
4904
+ return [...markers.content.children].map(segmentKey)
4905
+ }
4961
4906
 
4962
4907
  /**
4963
4908
  * A navigation issued from a HOST page (one rendering parallel-route slots) may be
@@ -4986,6 +4931,12 @@ function slotStateSensitive(state: DocumentNavState, cachedHtml?: string): boole
4986
4931
  */
4987
4932
  export function entryMatchesNavState(entry: PrefetchEntry, state: DocumentNavState): boolean {
4988
4933
  if (entry.stateKey === navStateKey(state)) return true
4934
+ if (entry.keptLayouts?.length && entry.slotsKey === slotsStateKey(state.slots ?? {})) {
4935
+ const live = new Set(
4936
+ [...document.body.querySelectorAll('pnext-layout[data-pnext-segment]')].map(segmentKey),
4937
+ )
4938
+ return !isHostPageNavState(state) && entry.keptLayouts.every(key => live.has(key))
4939
+ }
4989
4940
  // Interception host renders are host-bound: they only serve their exact host
4990
4941
  // nav state (matched above), never the origin-agnostic slot-only fallback.
4991
4942
  if (entry.originAgnostic !== true || entry.intercepted === true) return false
@@ -5143,6 +5094,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5143
5094
  if (refreshLike) evictNavigationCache(url.pathname + url.search, url.pathname)
5144
5095
 
5145
5096
  const sequence = ++navigationSequence
5097
+ // Another navigation supersedes the pending link.
5098
+ if (linkStatus?.[1] !== sequence) settleLinkStatus()
5146
5099
  // Snapshot the departing entry before a cached/streamed loading shell paints
5147
5100
  // into the live body. Capturing later would save the target's fallback as
5148
5101
  // the previous history entry and restore a permanently stuck "Loading...".
@@ -5212,26 +5165,45 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5212
5165
  // server-side, and re-painting would replace a committed loading state with a second,
5213
5166
  // different one. A segment's loading fallback commits ONCE per navigation.
5214
5167
  let cachedStagePainted = false
5168
+ // A cached paint that left this layout on screen kept the chain the response grafts onto.
5169
+ const departingLayout = document.body.querySelector('pnext-layout[data-pnext-segment]')
5215
5170
  // The route whose islands a stage paint mounted; the commit keeps them instead of remounting.
5216
5171
  let paintedRoute: BrowserRouteState | undefined
5172
+ // An early paint waits on its sheets (React's 60 s cap), then paints unheld. The newest wins;
5173
+ // another paint or the response landing first drops it.
5174
+ let heldPaint: unknown
5175
+ const early = <T>(paint: (hold?: SheetHold) => T) =>
5176
+ paint(sheets => {
5177
+ const go = () => heldPaint === paint && paint()
5178
+ heldPaint = paint
5179
+ setTimeout(go, 6e4)
5180
+ void Promise.all(sheets).then(go)
5181
+ })
5217
5182
  // A painted loading boundary or stage is a committed navigation state.
5218
5183
  const commitPaint = () => {
5184
+ heldPaint = undefined
5219
5185
  paintedRoute = window.__PNEXT_ROUTE__
5220
5186
  if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
5221
5187
  routerState.fallbackPaint = [sequence, performance.now()]
5222
5188
  pushOptimisticUrl()
5223
5189
  scheduleNavigationScroll(url, options)
5190
+ settleLinkStatus(sequence)
5224
5191
  }
5225
5192
  const devSoftNavigation = nextDevDocument()
5193
+ // A held shell can paint after its caller moved on, so a later twin must not repaint it.
5194
+ let shellCommitted = false
5226
5195
  const onShell =
5227
5196
  restorePage || options.pop || refreshLike
5228
5197
  ? undefined
5229
- : (shellHtml: string) => {
5230
- if (cachedStagePainted || devSoftNavigation) return false
5231
- if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return false
5232
- commitPaint()
5233
- return true
5234
- }
5198
+ : (shellHtml: string) =>
5199
+ early(hold => {
5200
+ if (cachedStagePainted || shellCommitted || devSoftNavigation) return false
5201
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true, hold))
5202
+ return false
5203
+ shellCommitted = true
5204
+ commitPaint()
5205
+ return true
5206
+ })
5235
5207
  // The per-segment cache's answer for this navigation, looked up ONCE: it decides both
5236
5208
  // whether the generic loading shell should paint (a real cached static segment is strictly
5237
5209
  // better) and, in pageForNavigation, whether the navigation commits network-free.
@@ -5246,12 +5218,13 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5246
5218
  // Paint a cached STATIC STAGE — the route's own content, not a fallback — and
5247
5219
  // treat it as a committed navigation exactly like a loading shell.
5248
5220
  const onStaticStage = onShell
5249
- ? (html: string, postponedShell = false) => {
5250
- if (devSoftNavigation) return
5251
- if (!commitStaticStage(html, sequence, url, postponedShell)) return
5252
- cachedStagePainted = true
5253
- commitPaint()
5254
- }
5221
+ ? (html: string, postponedShell = false) =>
5222
+ early(hold => {
5223
+ if (devSoftNavigation || !commitStaticStage(html, sequence, url, postponedShell, hold))
5224
+ return
5225
+ cachedStagePainted = true
5226
+ commitPaint()
5227
+ })
5255
5228
  : undefined
5256
5229
  // Loading-shell reuse: when the navigation must fetch, paint the CACHED shell for the
5257
5230
  // target pathname synchronously - search-param independent, so `?a` reuses the shell a `?b`
@@ -5268,23 +5241,23 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5268
5241
  pathname: url.pathname,
5269
5242
  search: url.search,
5270
5243
  }) &&
5271
- !hasFreshDocument(url) &&
5272
- !peekSegmentBody(url.pathname)
5244
+ !hasFreshDocument(url)
5273
5245
  ) {
5274
5246
  const stored = takeShellForUrl(url)
5275
5247
  const predicted = loadingShellPredictionPolicy?.(
5276
5248
  url,
5277
5249
  stored ? new Map([[stored.key, stored.html]]) : shellHtmlByPath(),
5278
5250
  )
5279
- const shellPainted = predicted
5280
- ? showLoadingShell(predicted.html, sequence, url, predicted.route)
5281
- : stored
5282
- ? showLoadingShell(stored.html, sequence, url)
5283
- : false
5284
- if (shellPainted) {
5251
+ const shell = predicted ?? stored
5252
+ early(hold => {
5253
+ if (
5254
+ !shell ||
5255
+ !showLoadingShell(shell.html, sequence, url, predicted?.route, false, false, hold)
5256
+ )
5257
+ return
5285
5258
  cachedStagePainted = true
5286
5259
  commitPaint()
5287
- }
5260
+ })
5288
5261
  }
5289
5262
  // Which navigations may fetch the PAGE frame alone. Never on popstate (a history entry
5290
5263
  // restores its own render, layouts included) and never for a refresh/action revalidation,
@@ -5320,6 +5293,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5320
5293
  departureUrl,
5321
5294
  pageFrame,
5322
5295
  ))
5296
+ // The payload commits styled on its own; a shell still held for its sheets never paints.
5297
+ heldPaint = undefined
5323
5298
  if (sequence !== navigationSequence) return abandonFetchedPage(page)
5324
5299
  if (!page) {
5325
5300
  // Offline: a hard navigation would land on the browser's error page and lose the app
@@ -5353,7 +5328,9 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5353
5328
  !restorePage &&
5354
5329
  skippedSegmentsUngraftable(
5355
5330
  doc,
5356
- options.freshSegments || cachedStagePainted || (refreshLike && !options.pageRefresh),
5331
+ options.freshSegments ||
5332
+ (cachedStagePainted && !departingLayout?.isConnected) ||
5333
+ (refreshLike && !options.pageRefresh),
5357
5334
  )
5358
5335
  ) {
5359
5336
  const fullPage = await fetchPage(url.href, {
@@ -5488,7 +5465,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5488
5465
  // skippedSegmentsUngraftable above.
5489
5466
  const preservedSegments =
5490
5467
  options.freshSegments ||
5491
- cachedStagePainted ||
5468
+ (cachedStagePainted && !departingLayout?.isConnected) ||
5492
5469
  (refreshLike && !options.pageRefresh) ||
5493
5470
  parallelSlotsChanged
5494
5471
  ? (clearSegmentPreserveTags(doc), [])
@@ -5673,6 +5650,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5673
5650
  // subscriber observes the matching tree (the same ordering as the former end-of-commit pair).
5674
5651
  emitNavigationCommit()
5675
5652
  emitLocationChange()
5653
+ settleLinkStatus(sequence)
5676
5654
  // History traversal restores the popped entry's form state over the freshly mounted tree
5677
5655
  // (browser back/forward form restoration semantics). This runs BEFORE the paint hold's
5678
5656
  // settling frame below: mounting can recreate the controls the pre-mount fill populated,
@@ -5719,7 +5697,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5719
5697
  // islandOwnedControl). The pop equivalent runs inside the commit above, ahead of the paint
5720
5698
  // hold's settling frame.
5721
5699
  if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5722
- restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
5700
+ restoreFormStateWhenMounted(departingBfcacheId, sequence, islandOwnedControl)
5723
5701
  }
5724
5702
  }
5725
5703
 
@@ -5781,11 +5759,7 @@ function saveFormState(bfcacheId: string) {
5781
5759
  }))
5782
5760
  entryFormStateCache.delete(bfcacheId)
5783
5761
  if (state.some(control => control.value !== '')) entryFormStateCache.set(bfcacheId, state)
5784
- while (entryFormStateCache.size > ENTRY_FORM_STATE_CACHE_LIMIT) {
5785
- const oldest = entryFormStateCache.keys().next().value
5786
- if (oldest === undefined) break
5787
- entryFormStateCache.delete(oldest)
5788
- }
5762
+ trimOldest(entryFormStateCache, ENTRY_FORM_STATE_CACHE_LIMIT)
5789
5763
  }
5790
5764
 
5791
5765
  function restoreControl(
@@ -5814,14 +5788,18 @@ function islandOwnedControl(control: Element): boolean {
5814
5788
  return Boolean(root?.__pnextLive)
5815
5789
  }
5816
5790
 
5817
- function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false, skipIslands = false) {
5791
+ function restoreFormState(
5792
+ bfcacheId: string | undefined,
5793
+ onlyEmpty = false,
5794
+ skip?: (control: Element) => boolean,
5795
+ ) {
5818
5796
  if (!bfcacheId) return
5819
5797
  const state = entryFormStateCache.get(bfcacheId)
5820
5798
  if (!state) return
5821
5799
  const controls = formControls()
5822
5800
  if (controls.length !== state.length) return
5823
5801
  for (const [index, control] of controls.entries()) {
5824
- if (skipIslands && islandOwnedControl(control)) continue
5802
+ if (skip?.(control)) continue
5825
5803
  // `onlyEmpty`: a freshly mounted control is empty, so filling it restores
5826
5804
  // what the remount lost — while anything already carrying a value (the app
5827
5805
  // set it, or the user typed after the commit) is left alone.
@@ -5835,14 +5813,10 @@ function restoreSharedLayoutFormState(
5835
5813
  departingPathname: string,
5836
5814
  targetPathname: string,
5837
5815
  ) {
5838
- const state = entryFormStateCache.get(bfcacheId)
5839
- if (!state) return
5840
- const controls = formControls()
5841
- if (controls.length !== state.length) return
5842
5816
  const departing = departingPathname.split('/').filter(Boolean)
5843
5817
  const target = targetPathname.split('/').filter(Boolean)
5844
- for (const [index, control] of controls.entries()) {
5845
- if (elementInPageSlot(control)) continue
5818
+ restoreFormState(bfcacheId, false, control => {
5819
+ if (elementInPageSlot(control)) return true
5846
5820
  // Only controls rendered by the layout island itself belong to this
5847
5821
  // shared scope. A nested page island may sit inside that host after it
5848
5822
  // adopts the page slot, but its bfcacheId intentionally changes on a fresh
@@ -5850,11 +5824,10 @@ function restoreSharedLayoutFormState(
5850
5824
  const raw = control
5851
5825
  .closest('pnext-client[data-pnext-client]')
5852
5826
  ?.getAttribute('data-pnext-layout-segments')
5853
- if (!raw) continue
5827
+ if (!raw) return true
5854
5828
  const depth = (JSON.parse(raw) as { depth?: number }).depth
5855
- if (!depth || departing.slice(0, depth).join('/') !== target.slice(0, depth).join('/')) continue
5856
- restoreControl(control, state[index]!, false)
5857
- }
5829
+ return !depth || departing.slice(0, depth).join('/') !== target.slice(0, depth).join('/')
5830
+ })
5858
5831
  }
5859
5832
 
5860
5833
  /**
@@ -5863,10 +5836,14 @@ function restoreSharedLayoutFormState(
5863
5836
  * describes can appear after the swap. Keep trying while the DOM changes until the control
5864
5837
  * set matches the snapshot.
5865
5838
  */
5866
- function restoreFormStateWhenMounted(bfcacheId: string, sequence: number, skipIslands = false) {
5839
+ function restoreFormStateWhenMounted(
5840
+ bfcacheId: string,
5841
+ sequence: number,
5842
+ skip?: (control: Element) => boolean,
5843
+ ) {
5867
5844
  const state = entryFormStateCache.get(bfcacheId)
5868
5845
  if (!state) return
5869
- restoreFormState(bfcacheId, true, skipIslands)
5846
+ restoreFormState(bfcacheId, true, skip)
5870
5847
  // The controls can be rebuilt more than once while the commit settles, so the restore stays
5871
5848
  // armed for a short window instead of firing once. It only ever fills EMPTY controls, so a
5872
5849
  // value the app or the user set after the commit is never clobbered.
@@ -5875,7 +5852,7 @@ function restoreFormStateWhenMounted(bfcacheId: string, sequence: number, skipIs
5875
5852
  observer.disconnect()
5876
5853
  return
5877
5854
  }
5878
- restoreFormState(bfcacheId, true, skipIslands)
5855
+ restoreFormState(bfcacheId, true, skip)
5879
5856
  })
5880
5857
  observer.observe(document.body, { childList: true, subtree: true })
5881
5858
  window.setTimeout(() => observer.disconnect(), FORM_STATE_RESTORE_WINDOW_MS)
@@ -5898,7 +5875,7 @@ function onLinkClick(event: MouseEvent) {
5898
5875
  * can decide (and preventDefault) synchronously and replay the navigation here once the
5899
5876
  * router chunk lands.
5900
5877
  */
5901
- export function commitLinkNavigation({ url, replace, scroll }: LinkClickTarget) {
5878
+ export function commitLinkNavigation({ link, url, replace, scroll }: LinkClickTarget) {
5902
5879
  const samePage = url.pathname === location.pathname && url.search === location.search
5903
5880
 
5904
5881
  // A hash CHANGE on the current URL is pure scroll (no request). Clicking the
@@ -5909,6 +5886,7 @@ export function commitLinkNavigation({ url, replace, scroll }: LinkClickTarget)
5909
5886
  else history.pushState(historyState(), '', url.href)
5910
5887
  scheduleNavigationScroll(url, { scroll })
5911
5888
  emitLocationChange()
5889
+ settleLinkStatus()
5912
5890
  return
5913
5891
  }
5914
5892
 
@@ -5920,11 +5898,29 @@ export function commitLinkNavigation({ url, replace, scroll }: LinkClickTarget)
5920
5898
  // and broadcast still happen up front.
5921
5899
  scheduleNavigationScroll(url, { scroll })
5922
5900
  emitLocationChange()
5923
- void softNavigate(url.href, { replace, scroll, refreshLike: true, pageRefresh: true })
5924
- return
5925
5901
  }
5902
+ // useLinkStatus: the clicked Link is pending until its navigation paints, fails or is superseded.
5903
+ const hints = (link as LinkStatusAnchor).__pnextHints
5904
+ const sequence = navigationSequence + 1
5905
+ if (hints) {
5906
+ settleLinkStatus()
5907
+ for (const setPending of hints) setPending(true)
5908
+ linkStatus = [hints, sequence]
5909
+ }
5910
+ void softNavigate(
5911
+ url.href,
5912
+ samePage ? { replace, scroll, refreshLike: true, pageRefresh: true } : { replace, scroll },
5913
+ ).finally(() => settleLinkStatus(sequence))
5914
+ }
5915
+
5916
+ type LinkStatusAnchor = HTMLAnchorElement & { __pnextHints?: Set<(pending: boolean) => void> }
5917
+
5918
+ let linkStatus: [Set<(pending: boolean) => void>, number] | undefined
5926
5919
 
5927
- void softNavigate(url.href, { replace, scroll })
5920
+ function settleLinkStatus(sequence?: number) {
5921
+ if (!linkStatus || (sequence !== undefined && linkStatus[1] !== sequence)) return
5922
+ for (const setPending of linkStatus[0]) setPending(false)
5923
+ linkStatus = undefined
5928
5924
  }
5929
5925
 
5930
5926
  // A `prefetch={true}` Link marks itself `data-prefetch-full` so the router
@@ -6054,16 +6050,15 @@ export function pingVisiblePrefetchLinks() {
6054
6050
  prefetchedElements.delete(element)
6055
6051
  continue
6056
6052
  }
6057
- const href = element.getAttribute('href')
6058
- if (href) {
6059
- void prefetchRoute(href, {
6060
- element,
6061
- full: isFullPrefetchLink(element),
6062
- })
6063
- }
6053
+ prefetchLinkElement(element)
6064
6054
  }
6065
6055
  }
6066
6056
 
6057
+ function prefetchLinkElement(element: Element) {
6058
+ const href = element.getAttribute('href')
6059
+ if (href) void prefetchRoute(href, { element, full: isFullPrefetchLink(element) })
6060
+ }
6061
+
6067
6062
  // `load` links prefetch as soon as they appear; `visible` links when they
6068
6063
  // enter the viewport. Both arrive with the page or with later client renders,
6069
6064
  // so watch the whole document for additions.
@@ -6088,9 +6083,8 @@ function scanEagerPrefetchLinks(root: Element) {
6088
6083
  if (eagerLinks.has(link)) continue
6089
6084
  eagerLinks.add(link)
6090
6085
  if (mode === 'load') {
6091
- const href = link.getAttribute('href')
6092
6086
  if (isElementVisible(link)) visiblePrefetchElements.add(link)
6093
- if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
6087
+ prefetchLinkElement(link)
6094
6088
  continue
6095
6089
  }
6096
6090
  if (visibleLinkObserverDoc !== document) {
@@ -6110,20 +6104,13 @@ function scanEagerPrefetchLinks(root: Element) {
6110
6104
  continue
6111
6105
  }
6112
6106
  visiblePrefetchElements.add(entry.target)
6113
- const href = entry.target.getAttribute('href')
6114
- if (href)
6115
- void prefetchRoute(href, {
6116
- element: entry.target,
6117
- full: isFullPrefetchLink(entry.target),
6118
- })
6107
+ prefetchLinkElement(entry.target)
6119
6108
  }
6120
6109
  })
6121
6110
  visibleLinkObserver.observe(link)
6122
6111
  if (isElementVisible(link)) {
6123
6112
  visiblePrefetchElements.add(link)
6124
- const href = link.getAttribute('href')
6125
- if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
6126
- continue
6113
+ prefetchLinkElement(link)
6127
6114
  }
6128
6115
  }
6129
6116
  }
@@ -6160,10 +6147,7 @@ export function installRouterFull() {
6160
6147
  // Seed the hard-loaded entry's ids BEFORE any island mounts — the hub already
6161
6148
  // did that (see ./index.ts installRouter); this tier picks up from there.
6162
6149
  storeNavState()
6163
- // Next's initial RSC payload seeds the segment cache before request params resolve. PNext's hard
6164
- // HTML load carries only the resolved continuation, so obtain the prerendered current-route stage
6165
- // now and keep it as the cacheComponents segment seed. Default apps do no extra work.
6166
- if (activityBfcacheEnabled()) void prefetchRoute(location.href, { currentUrl: true })
6150
+ routerState.settleLink = settleLinkStatus
6167
6151
  // Bind every delayed hard-load operation to the document and history entry that installed this
6168
6152
  // runtime. `load` can fire after a fast soft navigation has already committed; consulting the
6169
6153
  // then-current location/state would file the original source under the destination entry key.
@@ -6188,6 +6172,11 @@ export function installRouterFull() {
6188
6172
  const hardLoadStaticStage = documentStaticHintFromHtml(hardLoadRestoreSource.html)?.isStatic
6189
6173
  ? undefined
6190
6174
  : preHydrationShell()
6175
+ // Next's initial RSC payload seeds the segment cache before request params resolve. The stashed
6176
+ // pre-hydration stage is that seed (filed at load); without one, fetch the prerendered
6177
+ // current-route stage as the cacheComponents seed. Default apps do no extra work.
6178
+ if (activityBfcacheEnabled() && !hardLoadStaticStage)
6179
+ void prefetchRoute(location.href, { currentUrl: true })
6191
6180
  // Entry-document identity is needed as soon as navigation can start, not at window.load.
6192
6181
  cacheEntryDocument(hardLoadRestoreSource)
6193
6182
  const hardLoadEntryId = historyState().__pnextEntry