@wular/pnext 0.0.24 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/src/api/client-navigation.ts +7 -5
  6. package/src/cli/adapters/vercel-warm.ts +120 -28
  7. package/src/cli/build.ts +56 -15
  8. package/src/cli/create.ts +1 -1
  9. package/src/cli/dev.ts +2 -1
  10. package/src/cli/index.ts +146 -10
  11. package/src/cli/migrate/report.ts +1 -1
  12. package/src/cli/serve/pipeline.ts +23 -6
  13. package/src/cli/serve/ui.ts +1 -1
  14. package/src/cli/start.ts +2 -1
  15. package/src/cli/typegen.ts +5 -2
  16. package/src/client/build.ts +217 -58
  17. package/src/client/chunk-fold.ts +7 -1
  18. package/src/client/entry.ts +44 -7
  19. package/src/client/router/runtime.ts +1016 -230
  20. package/src/client/router/types.ts +15 -0
  21. package/src/compat/actions/client-plugin.ts +10 -0
  22. package/src/compat/actions/rewrite.ts +1 -0
  23. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  24. package/src/compat/client/navigation-scroll.ts +29 -3
  25. package/src/compat/client/optimistic-routing.ts +2 -2
  26. package/src/compat/client/segment-cache-policy.ts +4 -18
  27. package/src/compat/client/segment-cache.ts +104 -0
  28. package/src/compat/client/segment-prefetch.ts +6 -6
  29. package/src/compat/next/config-loader.ts +6 -4
  30. package/src/compat/next/navigation.ts +16 -2
  31. package/src/compat/pages/router.ts +102 -9
  32. package/src/compat/protocol.ts +10 -1
  33. package/src/compat/register/actions.ts +2 -0
  34. package/src/compat/register/bundler.ts +4 -6
  35. package/src/compat/register/routing.ts +3 -4
  36. package/src/compat/tsconfig-defaults.ts +3 -2
  37. package/src/config.ts +3 -3
  38. package/src/css/postcss.ts +450 -17
  39. package/src/dev/client-actions.ts +2 -0
  40. package/src/dev/server.ts +87 -14
  41. package/src/render/island-context.ts +3 -0
  42. package/src/render/ppr.ts +14 -0
  43. package/src/render/renderer.ts +268 -42
  44. package/src/request/context.ts +32 -11
  45. package/src/resolve/scan-facts.ts +23 -4
  46. package/src/routing/proxy.ts +29 -25
  47. package/src/routing/routes.ts +1 -1
  48. package/src/runtime/loader.ts +47 -1
  49. package/src/runtime/modules.ts +546 -65
  50. package/src/runtime/vendor-build.ts +109 -22
  51. package/src/runtime/vendor.ts +7 -3
  52. package/src/utils/ansi.ts +3 -1
  53. package/src/utils/fs.ts +17 -3
@@ -45,7 +45,7 @@ import type {
45
45
  SoftNavigateOptions,
46
46
  } from './types'
47
47
  import type { LinkClickTarget } from './hub'
48
- import { elementInPageSlot, graftPageSlot, loadingShellTarget } from './page-slot'
48
+ import { elementInPageSlot, graftPageSlot, loadingShellTarget, pageSlotRange } from './page-slot'
49
49
  // ---------------------------------------------------------------------------
50
50
  // DOCUMENTS
51
51
  // ---------------------------------------------------------------------------
@@ -184,19 +184,6 @@ function currentNavState(): DocumentNavState {
184
184
  return state
185
185
  }
186
186
 
187
- /**
188
- * The static classification the server inlined into `#__PNEXT_NAV_STATE__`. Seeds
189
- * a hard load's prefetch entry with the route's true static reuse window.
190
- * Absent/parse-failure is treated as dynamic.
191
- */
192
- function documentStaticHint(): StaticHint | null {
193
- const script =
194
- typeof document.getElementById === 'function'
195
- ? document.getElementById('__PNEXT_NAV_STATE__')
196
- : null
197
- return staticHintFromJson(script?.textContent ?? null)
198
- }
199
-
200
187
  export interface StaticHint {
201
188
  isStatic: boolean
202
189
  staleTime?: number
@@ -215,15 +202,6 @@ function documentStaticHintFromHtml(html: string): StaticHint | null {
215
202
  return staticHintFromJson(navStateJson(html))
216
203
  }
217
204
 
218
- /**
219
- * True when the live document came from a `prefetch = 'allow-runtime'` route: it
220
- * has no shared static shell, so its cacheable stage is per-URL and only a
221
- * runtime prefetch can produce it.
222
- */
223
- function documentRuntimePrefetch(): boolean {
224
- return window.__PNEXT_ROUTE__?.runtimePrefetch === true
225
- }
226
-
227
205
  /** The same flag read out of a document's SOURCE (a navigation response). */
228
206
  function htmlRuntimePrefetch(html: string): boolean {
229
207
  return routeStateFromHtml(html)?.runtimePrefetch === true
@@ -258,14 +236,15 @@ function staticHintFromJson(json: string | null): StaticHint | null {
258
236
  }
259
237
 
260
238
  function rootLayoutId(doc: Document): string | null {
261
- return doc.documentElement?.getAttribute('data-pnext-root-layout') ?? null
239
+ return doc.documentElement?.getAttribute('data-pnext-root-layout')?.replace(/[?#].*/, '') ?? null
262
240
  }
263
241
 
264
242
  function rootLayoutChanged(incoming: Document) {
265
243
  const current = rootLayoutId(document)
266
244
  const next = rootLayoutId(incoming)
267
- if (current === null || next === null) return false
268
- return current !== next
245
+ // Import versions and cache-busting revisions describe the build that produced a document, not
246
+ // a different root layout. The renderer's canonical id never needs either suffix in production.
247
+ return current !== null && next !== null && current !== next
269
248
  }
270
249
 
271
250
  function isPNextDocument(doc: Document) {
@@ -469,26 +448,69 @@ function preloadModule(href: string) {
469
448
  document.head.append(link)
470
449
  }
471
450
 
472
- // Add the new document's stylesheets before the body swap and resolve once
473
- // they load, so the swap never paints unstyled. Resolves on error too — a
474
- // missing stylesheet should degrade styling, not wedge navigation.
475
- function addStylesheets(doc: Document) {
451
+ // Sheets this runtime appended that have not settled yet, keyed on absolute href. A paint
452
+ // that installs them and a later commit that awaits them are different calls, so the
453
+ // promise has to outlive the one that created the link.
454
+ const stylesheetLoads = new Map<string, [Promise<void>, () => void]>()
455
+
456
+ // Append the document's stylesheets SYNCHRONOUSLY and hand back the loads still in flight.
457
+ // Every path that puts the destination's content on screen goes through here: a paint that
458
+ // commits a navigation without its route's sheets shows unstyled content until the dynamic
459
+ // stage lands. Resolves on error too — a missing stylesheet should degrade styling, not
460
+ // wedge navigation.
461
+ function installStylesheets(doc: Document): Promise<void>[] {
476
462
  const pending: Promise<void>[] = []
477
463
  for (const link of doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')) {
478
464
  const href = link.getAttribute('href')
479
- if (!href || findStylesheet(href)) continue
480
- pending.push(
481
- new Promise(resolve => {
482
- const sheet = document.createElement('link')
483
- sheet.rel = 'stylesheet'
484
- sheet.href = href
485
- sheet.onload = () => resolve()
486
- sheet.onerror = () => resolve()
487
- document.head.append(sheet)
488
- }),
489
- )
465
+ if (!href) continue
466
+ const key = absoluteStylesheetHref(href)
467
+ const installed = findStylesheet(href)
468
+ if (installed) {
469
+ // Already in the document — possibly installed by an earlier paint this
470
+ // navigation, whose load the commit must still wait out.
471
+ const inFlight = stylesheetLoads.get(key!)
472
+ if (inFlight) {
473
+ // A memory-cached/restored sheet can become usable without replaying `load`.
474
+ // Its DOM-visible readiness is authoritative; release the earlier paint's waiter.
475
+ // findStylesheet can return an SSR/body-streamed or island-owned same-href link,
476
+ // so only settle the waiter recorded for the link this runtime created.
477
+ if (installed.sheet) inFlight[1]()
478
+ else pending.push(inFlight[0])
479
+ }
480
+ continue
481
+ }
482
+ const sheet = document.createElement('link')
483
+ sheet.rel = 'stylesheet'
484
+ sheet.href = href
485
+ let settle!: () => void
486
+ const load = new Promise<void>(resolve => {
487
+ settle = () => {
488
+ // The sheet can be pruned while its request is still in flight and then
489
+ // reinstalled by a back navigation. Do not let the abandoned request
490
+ // erase the newer request's promise from the dedup map.
491
+ if (stylesheetLoads.get(key!)?.[0] === load) {
492
+ stylesheetLoads.delete(key!)
493
+ }
494
+ resolve()
495
+ }
496
+ })
497
+ sheet.onload = settle
498
+ sheet.onerror = settle
499
+ if (key) {
500
+ stylesheetLoads.set(key, [load, settle])
501
+ }
502
+ document.head.append(sheet)
503
+ pending.push(load)
504
+ }
505
+ return pending
506
+ }
507
+
508
+ function absoluteStylesheetHref(href: string) {
509
+ try {
510
+ return new URL(href, location.href).href
511
+ } catch {
512
+ return
490
513
  }
491
- return Promise.all(pending)
492
514
  }
493
515
 
494
516
  // Route stylesheets the new document does not use accumulate across navigations;
@@ -522,7 +544,7 @@ function pnextStylesheet(href: string) {
522
544
 
523
545
  function findStylesheet(href: string) {
524
546
  const target = new URL(href, location.href).href
525
- return [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')].some(
547
+ return [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')].find(
526
548
  link => link.href === target,
527
549
  )
528
550
  }
@@ -555,7 +577,9 @@ function materializeClientIslandMarkers(root: Document) {
555
577
  const hasClientRuntime = Boolean(entryScriptSrc(root))
556
578
  while (walker.nextNode()) comments.push(walker.currentNode as Comment)
557
579
  for (const start of comments.reverse()) {
558
- const match = /^(pnext-client(?:-after)?|pnext-page):([^>]*)$/.exec(start.data)
580
+ const match = /^(pnext-client(?:-after)?|pnext-static-children|pnext-page):([^>]*)$/.exec(
581
+ start.data,
582
+ )
559
583
  const kind = match?.[1]
560
584
  const encoded = match?.[2]
561
585
  if (!kind || !encoded || !start.parentNode) continue
@@ -569,7 +593,8 @@ function materializeClientIslandMarkers(root: Document) {
569
593
  continue
570
594
  }
571
595
  const container = document.createElement('div')
572
- const tag = kind === 'pnext-page' ? 'div' : 'pnext-client'
596
+ const tag =
597
+ kind === 'pnext-page' ? 'div' : kind === 'pnext-client-after' ? 'pnext-client' : kind
573
598
  container.innerHTML = `<${tag} ${encoded}></${tag}>`
574
599
  const island = container.firstElementChild
575
600
  if (!island) continue
@@ -908,6 +933,95 @@ function matchPreservedClientPage(
908
933
  return { root, nodes }
909
934
  }
910
935
 
936
+ // When a client layout adopts the page slot, its marker range disappears from the LIVE DOM and the
937
+ // page's client components become ordinary children inside a preserved provider island. Give those
938
+ // components a route-entry key on the incoming adoption source: Preact then unmounts only the page
939
+ // subtree while PageTransition, providers, and layout ancestors stay mounted.
940
+ // `key` is consumed by createElement and is not exposed as an application prop.
941
+ function keySlotlessClientPage(doc: Document, key: string) {
942
+ const slot = pageSlotRange(doc.body)
943
+ if (!slot) return
944
+ const roots: Element[] = []
945
+ for (const node of slot[2]) {
946
+ if (!(node instanceof Element)) continue
947
+ if (node.matches('pnext-client[data-pnext-client]')) roots.push(node)
948
+ roots.push(...node.querySelectorAll('pnext-client[data-pnext-client]'))
949
+ }
950
+ for (const [index, root] of roots.entries()) {
951
+ try {
952
+ const props = JSON.parse(root.getAttribute('data-pnext-props') ?? '{}') as Record<
953
+ string,
954
+ unknown
955
+ >
956
+ props.key = `pnext-page:${key}:${index}`
957
+ root.setAttribute('data-pnext-props', JSON.stringify(props))
958
+ } catch {
959
+ // Invalid island props will be diagnosed by the entry mount; navigation must still commit.
960
+ }
961
+ }
962
+ }
963
+
964
+ function hasSlotlessClientRootLayout(doc: Document, preserved: LiveIslandRoot[]) {
965
+ if (pageSlotRange(document.body) !== null || preserved.length === 0) return false
966
+ return preserved.some((root, index) => {
967
+ // After adoption, a client root layout is itself a direct body child. Its incoming clone may
968
+ // expose the page marker through serialized children, but older/generated entries can keep the
969
+ // route-screen roots beside it instead, so the direct-body topology is the durable signal.
970
+ if (root.parentElement === document.body) return true
971
+ const placeholder = doc.querySelector<LiveIslandRoot>(`[${PRESERVE_ATTRIBUTE}="${index}"]`)
972
+ return placeholder !== null && pageSlotRange(placeholder) !== null
973
+ })
974
+ }
975
+
976
+ async function flushClientEffects() {
977
+ if (window.requestAnimationFrame) {
978
+ await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
979
+ } else {
980
+ await Promise.resolve()
981
+ }
982
+ }
983
+
984
+ // A slotless live tree cannot be partially unmounted through a DOM container: the page lives as
985
+ // adopted children of a preserved provider root. Re-render that root once with the incoming page
986
+ // marker emptied while it is still in the old body. The temporary departure URL keeps pathname-keyed
987
+ // layout effects stable; after Preact flushes the outgoing page's cleanup, the real target render can
988
+ // mount its page fresh against the already-fired popstate flag.
989
+ async function unmountSlotlessClientPage(
990
+ doc: Document,
991
+ preserved: LiveIslandRoot[],
992
+ entry: EntryModule | null | undefined,
993
+ departingRouteKey: string,
994
+ ): Promise<boolean> {
995
+ if (!entry?.mountRoute || preserved.length === 0) return false
996
+ let staged = false
997
+ for (let index = 0; index < preserved.length; index++) {
998
+ const placeholder = doc.querySelector<LiveIslandRoot>(`[${PRESERVE_ATTRIBUTE}="${index}"]`)
999
+ if (!placeholder) continue
1000
+ const source = placeholder.cloneNode(true) as LiveIslandRoot
1001
+ const slot = pageSlotRange(source)
1002
+ if (!slot) continue
1003
+ for (const node of slot[2]) node.remove()
1004
+ if (slot[0] instanceof Element) slot[0].replaceChildren()
1005
+ else slot[0].remove()
1006
+ slot[1]?.remove()
1007
+ preserved[index]!.__pnextIncoming = source
1008
+ staged = true
1009
+ }
1010
+ if (!staged) return false
1011
+
1012
+ const targetState: unknown = history.state
1013
+ const targetHref = location.href
1014
+ const departureHref = new URL(departingRouteKey, location.origin).href
1015
+ withSilentLocationChange(() => history.replaceState(targetState, '', departureHref))
1016
+ try {
1017
+ await entry.mountRoute()
1018
+ await flushClientEffects()
1019
+ } finally {
1020
+ withSilentLocationChange(() => history.replaceState(targetState, '', targetHref))
1021
+ }
1022
+ return true
1023
+ }
1024
+
911
1025
  // A query-only nav that stays on the SAME route renders the same whole-page `'use client'`
912
1026
  // root, so its live DOM must be preserved exactly like a refresh - otherwise the page
913
1027
  // REMOUNTS and a queued action's `setState` closure updates an orphaned component. The
@@ -925,6 +1039,7 @@ function swapBody(
925
1039
  preservedPage: PreservedClientPage | null = null,
926
1040
  reusable = reusableBodyChildren(),
927
1041
  ) {
1042
+ const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
928
1043
  const fragment = document.createDocumentFragment()
929
1044
  // The incoming document's entry src, remembered on <html> below so a snapshot
930
1045
  // of the swapped (script-less) live DOM can still name its entry.
@@ -950,7 +1065,10 @@ function swapBody(
950
1065
  const live = takeReusableBodyChild(reusable, node)
951
1066
  fragment.append(live ?? document.importNode(node, true))
952
1067
  }
953
- if (preserved.length > 0) graftPreservedIslands(fragment, preserved)
1068
+ const connected =
1069
+ preserved.length > 0
1070
+ ? graftPreservedIslands(fragment, preserved)
1071
+ : new Map<Node, LiveIslandRoot>()
954
1072
  if (segments.length > 0) graftPreservedServerSegments(fragment, segments)
955
1073
  if (preservedPage) {
956
1074
  const placeholder = fragment.querySelector(`[${PAGE_PRESERVE_ATTRIBUTE}]`)
@@ -958,19 +1076,135 @@ function swapBody(
958
1076
  // preact tree (and its state) carries straight into the new body.
959
1077
  if (placeholder) placeholder.replaceWith(...preservedPage.nodes)
960
1078
  }
961
- document.body.replaceChildren(fragment)
1079
+ if (connected.size === 0) {
1080
+ document.body.replaceChildren(fragment)
1081
+ } else {
1082
+ // Reconcile around direct-body layout roots without ever disconnecting them.
1083
+ let cursor = document.body.firstChild
1084
+ for (const desired of [...fragment.childNodes]) {
1085
+ const node = connected.get(desired) ?? desired
1086
+ if (node === cursor) cursor = cursor.nextSibling
1087
+ else document.body.insertBefore(node, cursor)
1088
+ }
1089
+ while (cursor) {
1090
+ const next = cursor.nextSibling
1091
+ cursor.remove()
1092
+ cursor = next
1093
+ }
1094
+ }
1095
+ if (paintHold) document.body.append(paintHold)
962
1096
  // Track the entry of what is now on screen (dropped when the incoming route
963
1097
  // has none, so a stale entry is never attributed to it).
964
1098
  if (entrySrc) document.documentElement.setAttribute(ENTRY_SCRIPT_ATTRIBUTE, entrySrc)
965
1099
  else document.documentElement.removeAttribute(ENTRY_SCRIPT_ATTRIBUTE)
966
1100
  }
967
1101
 
1102
+ /** Keep the last complete screen painted while client roots mount into the committed document. */
1103
+ type NavigationPaintHold = HTMLElement & {
1104
+ __pnextObserver?: MutationObserver
1105
+ }
1106
+
1107
+ function removeNavigationPaintHold(hold: NavigationPaintHold) {
1108
+ hold.__pnextObserver?.disconnect()
1109
+ hold.remove()
1110
+ }
1111
+
1112
+ function createNavigationPaintHold(): NavigationPaintHold | null {
1113
+ // A newer navigation may start before the prior hold's settling frame. Retire
1114
+ // that transaction synchronously so holds never nest (the older async release
1115
+ // becomes a no-op).
1116
+ const priorHold = document.querySelector<NavigationPaintHold>(
1117
+ '[data-pnext-navigation-paint-hold]',
1118
+ )
1119
+ if (priorHold) removeNavigationPaintHold(priorHold)
1120
+ const roots = [...document.body.children].filter(
1121
+ element =>
1122
+ !element.hasAttribute('data-pnext-navigation-paint-hold') &&
1123
+ !/^(SCRIPT|STYLE|LINK|TEMPLATE)$/.test(element.tagName) &&
1124
+ element.textContent?.trim(),
1125
+ )
1126
+ if (roots.length === 0) return null
1127
+ const hold = document.createElement('div')
1128
+ hold.setAttribute('data-pnext-navigation-paint-hold', '')
1129
+ hold.setAttribute('aria-hidden', 'true')
1130
+ hold.style.cssText =
1131
+ 'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
1132
+ hold.append(...roots.map(root => root.cloneNode(true)))
1133
+ // The copy is paint-only: route mount scans and id lookups must never treat it as live UI.
1134
+ for (const node of hold.querySelectorAll('[data-pnext-client]'))
1135
+ node.removeAttribute('data-pnext-client')
1136
+ for (const node of hold.querySelectorAll('[id]')) node.removeAttribute('id')
1137
+ // Cloned media are NEW elements: an `autoplay` attribute replays them on insertion, and
1138
+ // cloneNode copies attributes but not the live `muted` property - a video muted only via
1139
+ // property starts AUDIBLE in the copy. Freeze every clone on its current frame instead.
1140
+ for (const media of hold.querySelectorAll<HTMLMediaElement>('video,audio')) {
1141
+ media.removeAttribute('autoplay')
1142
+ media.muted = true
1143
+ media.setAttribute('muted', '')
1144
+ media.preload = 'none'
1145
+ media.removeAttribute('src')
1146
+ for (const source of media.querySelectorAll('source')) source.remove()
1147
+ }
1148
+ // A cloned iframe re-loads (and can re-play) its document; the paint copy needs only the box.
1149
+ for (const frame of hold.querySelectorAll('iframe')) frame.removeAttribute('src')
1150
+ return hold
1151
+ }
1152
+
1153
+ function attachNavigationPaintHold(
1154
+ hold: NavigationPaintHold | null,
1155
+ scrollTop: number,
1156
+ sequence: number,
1157
+ ) {
1158
+ if (!hold || sequence !== navigationSequence) return
1159
+ // The opaque fixed clone covers the viewport while the committed tree mounts.
1160
+ // Keep that live tree visible to focus management and selector observers;
1161
+ // hiding <body> also hides every real destination element from browser gates.
1162
+ document.body.append(hold)
1163
+ hold.__pnextObserver ??= new MutationObserver(() => {
1164
+ if (sequence !== navigationSequence) {
1165
+ return removeNavigationPaintHold(hold)
1166
+ }
1167
+ if (!hold.isConnected) document.body.append(hold)
1168
+ })
1169
+ hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
1170
+ const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
1171
+ if (scrollRoot) scrollRoot.scrollTop = scrollTop
1172
+ }
1173
+
1174
+ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null) {
1175
+ if (!hold) return
1176
+ // mountRoute, client effects and the navigation commit have completed before
1177
+ // this runs. Keep the departing frame through the next rendering opportunity,
1178
+ // then reveal the committed tree; content length is not a readiness signal.
1179
+ if (window.requestAnimationFrame) {
1180
+ await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
1181
+ }
1182
+ removeNavigationPaintHold(hold)
1183
+ }
1184
+
968
1185
  /**
969
1186
  * Give focus back to the element that had it before the body swap: reattaching a
970
1187
  * node blurs it, so a retained layout element would silently drop focus to <body>.
971
1188
  * Focus the new tree already claimed (autofocus, segment focus) stands.
972
1189
  */
973
- function restoreSwapFocus(focused: Element | null): void {
1190
+ function restoreSwapFocus(
1191
+ focused: Element | null,
1192
+ navigationTarget: HTMLElement | null = null,
1193
+ ): void {
1194
+ // Next applies scroll/focus after the changed segment has committed. pnext
1195
+ // resolves that segment before a client root can dissolve its page markers,
1196
+ // so remember the element the scroll action focused and reaffirm it after
1197
+ // client reconciliation. This is intentionally ahead of restoring retained
1198
+ // layout focus: an interactive/scrollable destination segment wins over the
1199
+ // link that initiated the navigation.
1200
+ if (navigationTarget?.isConnected) {
1201
+ try {
1202
+ navigationTarget.focus({ preventScroll: true })
1203
+ } catch {
1204
+ // Focus is best-effort; continue with retained-layout focus below.
1205
+ }
1206
+ if (document.activeElement === navigationTarget) return
1207
+ }
974
1208
  if (!(focused instanceof HTMLElement) || focused === document.body) return
975
1209
  if (!focused.isConnected) return
976
1210
  if (document.activeElement !== null && document.activeElement !== document.body) return
@@ -1249,6 +1483,7 @@ function disposeCachedRoot(root: LiveIslandRoot) {
1249
1483
  // fresh island props move onto the live element, and the detached placeholder (fresh
1250
1484
  // SSR children) is stashed for the entry's mountRoute to re-render in place.
1251
1485
  function graftPreservedIslands(fragment: DocumentFragment, preserved: LiveIslandRoot[]) {
1486
+ const connected = new Map<Node, LiveIslandRoot>()
1252
1487
  for (const placeholder of [...fragment.querySelectorAll(`[${PRESERVE_ATTRIBUTE}]`)]) {
1253
1488
  const live = preserved[Number(placeholder.getAttribute(PRESERVE_ATTRIBUTE))]
1254
1489
  placeholder.removeAttribute(PRESERVE_ATTRIBUTE)
@@ -1260,8 +1495,15 @@ function graftPreservedIslands(fragment: DocumentFragment, preserved: LiveIsland
1260
1495
  live.setAttribute(attribute.name, attribute.value)
1261
1496
  }
1262
1497
  live.__pnextIncoming = placeholder
1263
- placeholder.replaceWith(live)
1498
+ if (live.parentNode === document.body && placeholder.parentNode === fragment) {
1499
+ const marker = document.createComment('pnext-preserved-root')
1500
+ placeholder.replaceWith(marker)
1501
+ connected.set(marker, live)
1502
+ } else {
1503
+ placeholder.replaceWith(live)
1504
+ }
1264
1505
  }
1506
+ return connected
1265
1507
  }
1266
1508
 
1267
1509
  function isDevDocument() {
@@ -1278,17 +1520,19 @@ function hardNavigate(href: string, replace?: boolean) {
1278
1520
 
1279
1521
  function saveScrollPosition() {
1280
1522
  try {
1281
- const snapshot = {
1282
- html: `<!doctype html>${document.documentElement.outerHTML}`,
1283
- finalUrl: location.href,
1284
- ok: true,
1285
- }
1286
- cacheEntryDocument(snapshot)
1287
- // Preserve the original commit time so the entry expires on the document's real
1288
- // age, not the departure moment.
1289
- storeBfDoc(location.pathname + location.search, snapshot, { preserveTime: true })
1523
+ // The bfcache document is recorded when the route commits (and when a hard load settles).
1524
+ // Do not overwrite it from entryDocCache here: a streamed hard load intentionally keeps its
1525
+ // immutable pre-runtime source for history restoration, which can be only the loading shell.
1526
+ // Refiling that source on departure would downgrade the complete settled bfcache document and
1527
+ // make a full-prefetch Link back to the route commit a permanently suspended shell.
1528
+ // The scrollable height rides along so a pop restore into a still-short swapped body can
1529
+ // reserve it — the browser clamps scrollTo against the live height, silently losing the
1530
+ // position when the restored content mounts a beat after the swap.
1290
1531
  history.replaceState(
1291
- { ...historyState(), __pnextScroll: [window.scrollX, window.scrollY] },
1532
+ {
1533
+ ...historyState(),
1534
+ __pnextScroll: [window.scrollX, window.scrollY, document.documentElement.scrollHeight],
1535
+ },
1292
1536
  '',
1293
1537
  location.href,
1294
1538
  )
@@ -2005,6 +2249,14 @@ export function evictClientRouterCache(options: { rearmVisiblePrefetches?: boole
2005
2249
  entryDocCache.clear()
2006
2250
  bfDocCache.clear()
2007
2251
  urlStaticFreshUntil.clear()
2252
+ // Element sets outlive an evicted cache otherwise: in a multi-document host (tests, jsdom
2253
+ // embedders) the previous document's links stay registered and get re-pinged against the next
2254
+ // document's tree, spending prefetches nothing asked for.
2255
+ visiblePrefetchElements.clear()
2256
+ // Pending loads belong to the document that created their <link> elements. In a
2257
+ // multi-document host (tests, jsdom embedders) a never-settling promise keyed by an
2258
+ // absolute href would otherwise stall the next document's first commit on the same URL.
2259
+ stylesheetLoads.clear()
2008
2260
  for (const entry of invalidated) notifyPrefetchInvalidation(entry, true)
2009
2261
  if (options.rearmVisiblePrefetches !== false) rearmVisiblePrefetches()
2010
2262
  }
@@ -2172,6 +2424,17 @@ function segmentSchedulerEnabled(): boolean {
2172
2424
  )
2173
2425
  }
2174
2426
 
2427
+ // Next always restores a history entry's bfcacheId, but retaining the inactive
2428
+ // React tree in an <Activity> boundary is exclusive to cacheComponents. Compat
2429
+ // stamps this exact value into the document; an unstamped core app must keep its
2430
+ // ordinary unmount/remount lifecycle.
2431
+ function activityBfcacheEnabled(): boolean {
2432
+ return (
2433
+ (process.browser || typeof window !== 'undefined') &&
2434
+ (window as { __PNEXT_SEGMENT_SCHEDULER__?: boolean }).__PNEXT_SEGMENT_SCHEDULER__ === true
2435
+ )
2436
+ }
2437
+
2175
2438
  function acquirePrefetchSlot(task: PrefetchFetchTask, phase: number): Promise<boolean> {
2176
2439
  task.phase = phase
2177
2440
  if (task.cancelled) return Promise.resolve(false)
@@ -2276,6 +2539,11 @@ export interface PrefetchEntry {
2276
2539
 
2277
2540
  const prefetchCache = new Map<string, PrefetchEntry>()
2278
2541
  const prefetchedElements = new Set<Element>()
2542
+ // Next re-evaluates every currently visible Link when the committed URL/base tree changes:
2543
+ // the same href may need a different segment delta from the new route. Keep this separate from
2544
+ // `prefetchedElements` (which also includes intent-only and formerly visible elements used by
2545
+ // revalidation) so an ordinary navigation only pings links that are actually on screen.
2546
+ const visiblePrefetchElements = new Set<Element>()
2279
2547
 
2280
2548
  function touchCacheEntry<T>(cache: Map<string, T>, key: string, entry: T): void {
2281
2549
  // Map insertion order is our LRU order. Reads must move an entry to the end
@@ -2598,12 +2866,13 @@ export function prefetchRoute(
2598
2866
  const key = url.pathname + url.search
2599
2867
  const full = Boolean(options.full)
2600
2868
  const cacheKey = prefetchCacheKey(key, full)
2869
+ // Track the element before the current-route short circuit. A visible link to the page we are
2870
+ // already on has an empty prefetch delta now, but its delta changes after navigating away and
2871
+ // it must be reconsidered against that new base tree.
2872
+ if (options.element) prefetchedElements.add(options.element)
2601
2873
  // Prefetching the page we are already on is a no-op (Next's router produces
2602
2874
  // an empty delta for the current tree) — and it must not spend a request.
2603
- if (
2604
- (key === location.pathname + location.search || key === routerState.activeRouteKey) &&
2605
- !options.currentUrl
2606
- ) {
2875
+ if ((key === locationKey() || key === routerState.activeRouteKey) && !options.currentUrl) {
2607
2876
  return Promise.resolve(null)
2608
2877
  }
2609
2878
  // A default prefetch carries only static segment data; search params are dynamic data
@@ -2612,7 +2881,6 @@ export function prefetchRoute(
2612
2881
  if (!options.full && url.pathname === location.pathname && !options.currentUrl) {
2613
2882
  return Promise.resolve(null)
2614
2883
  }
2615
- if (options.element) prefetchedElements.add(options.element)
2616
2884
  if (revalidationPrefetchBlocked) return Promise.resolve(null)
2617
2885
  // Hover intent on a link whose prefetch is already scheduled/pending: boost
2618
2886
  // it to the reserved Intent lane instead of spawning anything new.
@@ -2888,13 +3156,13 @@ function rscHash(input: string): string {
2888
3156
  return (hash >>> 0).toString(36)
2889
3157
  }
2890
3158
 
2891
- // Read a streamed HTML response, delivering the shell to `onShell` the moment the server
2892
- // flushes it, then returning the full buffered document. The shell carries every Suspense
2893
- // fallback as a closed `<pnext-suspense>`, so the first read containing `</pnext-suspense>`
2894
- // is the shell; it is sliced at the first `<template data-pnext-stream>` already in the
2895
- // same read. A route with no Suspense boundary never fires onShell. The in-place
2896
- // suspending path closes its fallback with preact's `<!--/$s:ID-->` comment instead and
2897
- // streams content as `<preact-island data-target>`.
3159
+ // Read a streamed HTML response, offering the shell at the response-chunk boundary where the
3160
+ // navigation can commit it. A pending Suspense hole paints its fallback at that commit, exactly
3161
+ // like a browser receiving a streamed document shell; if the shell and its replacement arrive in
3162
+ // the same read, the complete content wins without an intermediate paint. The shell carries every
3163
+ // Suspense fallback as a closed `<pnext-suspense>`; replacement IDs distinguish resolved holes.
3164
+ // A route with no Suspense boundary never fires onShell. The in-place suspending path closes its
3165
+ // fallback with preact's `<!--/$s:ID-->` comment and streams content as `<preact-island data-target>`.
2898
3166
  const STREAM_CHUNK_MARKER = '<div hidden data-pnext-stream'
2899
3167
  const INLINE_CHUNK_MARKER = '<div hidden><preact-island'
2900
3168
  const SUSPENSE_FALLBACK_CLOSE = '</pnext-suspense>'
@@ -2903,13 +3171,43 @@ const SUSPENSE_FALLBACK_CLOSE = '</pnext-suspense>'
2903
3171
  const INLINE_FALLBACK_CLOSE = '</pnext-hole>'
2904
3172
 
2905
3173
  function streamChunkCut(buffer: string) {
2906
- const cuts = [buffer.indexOf(STREAM_CHUNK_MARKER), buffer.indexOf(INLINE_CHUNK_MARKER)].filter(
2907
- index => index !== -1,
3174
+ const streamed = buffer.indexOf(STREAM_CHUNK_MARKER)
3175
+ const inline = buffer.indexOf(INLINE_CHUNK_MARKER)
3176
+ return streamed < 0 ? inline : inline < 0 ? streamed : Math.min(streamed, inline)
3177
+ }
3178
+
3179
+ // A network read can end halfway through its final continuation. DOMParser auto-closes that
3180
+ // carrier, which would make materializeStreamedSegments graft partial markup over the fallback.
3181
+ // Stream carriers are top-level divs, so balance their nested div tags and retain every complete
3182
+ // same-read carrier while hiding the incomplete trailing one from shell logic.
3183
+ function completeStreamedPrefix(buffer: string): string {
3184
+ const start = Math.max(
3185
+ buffer.lastIndexOf(STREAM_CHUNK_MARKER),
3186
+ buffer.lastIndexOf(INLINE_CHUNK_MARKER),
2908
3187
  )
2909
- return cuts.length > 0 ? Math.min(...cuts) : -1
3188
+ if (start < 0) return buffer
3189
+ const tail = buffer.slice(start)
3190
+ return (tail.match(/<div\b/g)?.length ?? 0) === (tail.match(/<\/div>/g)?.length ?? 0)
3191
+ ? buffer
3192
+ : buffer.slice(0, start)
3193
+ }
3194
+
3195
+ function streamHasPendingHole(buffer: string) {
3196
+ for (const match of buffer.matchAll(/data-pnext-(?:suspense|hole)="([^"]+)"/g)) {
3197
+ const suffix = `="${match[1]}"`
3198
+ if (
3199
+ !buffer.includes(`data-pnext-stream${suffix}`) &&
3200
+ // Scope data-target to pnext's exact inline continuation wire form; app markup commonly
3201
+ // uses the bare attribute for toggles and tabs.
3202
+ !buffer.includes(`<preact-island hidden data-target${suffix}`)
3203
+ ) {
3204
+ return true
3205
+ }
3206
+ }
3207
+ return false
2910
3208
  }
2911
3209
 
2912
- async function readStreamedBody(
3210
+ export async function readStreamedBody(
2913
3211
  response: Response,
2914
3212
  onShell: (shellHtml: string) => void,
2915
3213
  ): Promise<string> {
@@ -2921,20 +3219,23 @@ async function readStreamedBody(
2921
3219
  for (;;) {
2922
3220
  const { done, value } = await reader.read()
2923
3221
  if (value) buffer += decoder.decode(value, { stream: true })
3222
+ const shellBuffer = completeStreamedPrefix(buffer)
2924
3223
  if (
2925
3224
  !shellDelivered &&
2926
- (buffer.includes(SUSPENSE_FALLBACK_CLOSE) || buffer.includes(INLINE_FALLBACK_CLOSE)) &&
3225
+ (shellBuffer.includes(SUSPENSE_FALLBACK_CLOSE) ||
3226
+ shellBuffer.includes(INLINE_FALLBACK_CLOSE)) &&
2927
3227
  // A read that already ends the document has nothing left to stream: painting the
2928
3228
  // pre-chunk loading shell would commit a fallback stage the immediate full-document
2929
3229
  // swap replaces, and that replacement rides island mounts. Skip the shell and let
2930
3230
  // the caller swap the complete, materialized document.
2931
- !/<\/html>\s*$/.test(buffer)
3231
+ !/<\/html>\s*$/.test(shellBuffer)
2932
3232
  ) {
2933
- shellDelivered = true
2934
- const cut = streamChunkCut(buffer)
2935
- const shell = cut === -1 ? buffer : buffer.slice(0, cut)
2936
- // Close the sliced shell so DOMParser sees a well-formed document.
2937
- onShell(`${shell}</body></html>`)
3233
+ if (streamHasPendingHole(shellBuffer)) {
3234
+ shellDelivered = true
3235
+ // Expose only holes still pending at this commit. Continuations already present in the
3236
+ // read are included so showLoadingShell can materialize them before choosing a fallback.
3237
+ onShell(`${shellBuffer}</body></html>`)
3238
+ }
2938
3239
  }
2939
3240
  // A late metadata tail (LATE_METADATA_HEADER) rides after the document bytes, so the
2940
3241
  // navigation must not wait for it: everything before the marker IS the document and
@@ -3011,6 +3312,8 @@ async function fetchPage(
3011
3312
  href: string,
3012
3313
  options: {
3013
3314
  navState?: DocumentNavState
3315
+ /** Rendered URL this navigation departed from (popstate location already names the target). */
3316
+ fromUrl?: string
3014
3317
  /**
3015
3318
  * 'auto' - a default prefetch (sends `next-router-prefetch`). 'full' - a
3016
3319
  * `prefetch={true}` full-page prefetch, which Next issues WITHOUT that header.
@@ -3048,7 +3351,7 @@ async function fetchPage(
3048
3351
  'next-router-state-tree': encodeURIComponent(JSON.stringify(state)),
3049
3352
  // The current URL identifies the already-rendered branch. A prefetch uses
3050
3353
  // it to retain shared layouts and stop at the target loading boundary.
3051
- 'next-url': location.pathname + location.search,
3354
+ 'next-url': options.fromUrl ?? locationKey(),
3052
3355
  }
3053
3356
  if (options.prefetch === 'auto') headers['next-router-prefetch'] = '1'
3054
3357
  // Only the streamed-navigation read path (readStreamedBody) can consume a
@@ -3413,7 +3716,7 @@ async function fetchPage(
3413
3716
  */
3414
3717
  async function fetchPageFrameNavigation(
3415
3718
  url: URL,
3416
- options: { navState?: DocumentNavState; sameUrl: boolean },
3719
+ options: { navState?: DocumentNavState; sameUrl: boolean; fromUrl?: string },
3417
3720
  ): Promise<PrefetchedPage | null> {
3418
3721
  const policy = segmentCachePolicy
3419
3722
  // `output: 'export'` has no server to negotiate a frame with.
@@ -3431,7 +3734,7 @@ async function fetchPageFrameNavigation(
3431
3734
  'x-pnext-soft-nav': '1',
3432
3735
  'x-pnext-nav-state': encodeURIComponent(JSON.stringify(state)),
3433
3736
  'next-router-state-tree': encodeURIComponent(JSON.stringify(state)),
3434
- 'next-url': location.pathname + location.search,
3737
+ 'next-url': options.fromUrl ?? locationKey(),
3435
3738
  [SEGMENT_PREFETCH_HEADER]: PAGE_SEGMENT_REQUEST_PATH,
3436
3739
  }
3437
3740
  const variant = `nav:${PAGE_SEGMENT_REQUEST_PATH}`
@@ -3531,6 +3834,10 @@ function recordSegmentEntry(input: {
3531
3834
  // and satisfies a sibling's prefetch without ever committing network-free.
3532
3835
  static: input.segmentPrerendered || input.shellPrerendered,
3533
3836
  runtime,
3837
+ // The server TRUNCATED this response at its postponed boundary: the payload is this
3838
+ // URL's own prerendered static stage, so a navigation may paint it however little of
3839
+ // the page sits outside the holes. A runtime sample is not that - it is request data.
3840
+ postponedShell: input.shellOnly && !runtime,
3534
3841
  })
3535
3842
  }
3536
3843
 
@@ -3660,13 +3967,20 @@ function mergeOutlinedHead(html: string, headFragment: string): string {
3660
3967
  // (anchorInlineSuspenseHoles). Return the first hole's parent plus the fallback nodes it
3661
3968
  // wraps, so the shell paint has the same anchor and source shape as the
3662
3969
  // `<pnext-suspense>` path.
3663
- function inlineSuspenseRange(doc: Document): { anchor: Element; nodes: Node[] } | null {
3664
- const hole = doc.querySelector('pnext-hole[data-pnext-hole]')
3970
+ function inlineSuspenseRange(
3971
+ doc: Document,
3972
+ ): { anchor: Element; nodes: Node[]; hole: Element } | null {
3973
+ // A loading.js hole carries the depth attribute the renderer lifted onto it; prefer it over
3974
+ // an app `<Suspense>` hole exactly as the marker wire form prefers its stamped marker.
3975
+ const hole =
3976
+ doc.querySelector(`pnext-hole[data-pnext-hole][${LOADING_DEPTH_ATTRIBUTE}]`) ??
3977
+ doc.querySelector('pnext-hole[data-pnext-hole]')
3665
3978
  if (!hole?.parentElement) return null
3666
- return { anchor: hole.parentElement, nodes: [...hole.childNodes] }
3979
+ return { anchor: hole.parentElement, nodes: [...hole.childNodes], hole }
3667
3980
  }
3668
3981
 
3669
- function showLoadingShell(
3982
+ /** Exported for the DOM-level unit test (see `paintStaticStageSubtree`). */
3983
+ export function showLoadingShell(
3670
3984
  shellHtml: string,
3671
3985
  sequence: number,
3672
3986
  target: URL,
@@ -3678,23 +3992,48 @@ function showLoadingShell(
3678
3992
  * STAGE so its fallbacks are unwrapped - committed content carries no stream wrappers.
3679
3993
  */
3680
3994
  allowWithoutBoundary = false,
3995
+ /** The shell came from this navigation's live response at its commit boundary. */
3996
+ allowResponseSuspense = false,
3681
3997
  ) {
3682
3998
  if (sequence !== navigationSequence) return false
3683
3999
  if (typeof DOMParser === 'undefined') return false
4000
+ const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
3684
4001
  const doc = new DOMParser().parseFromString(shellHtml, 'text/html')
3685
4002
  materializeClientIslandMarkers(doc)
3686
- const marker = doc.querySelector('pnext-suspense[data-pnext-suspense]')
4003
+ // A STATIC STAGE owns whatever chunks streamed with it, so resolve them before picking what
4004
+ // to paint - `paintStaticStageSubtree` already does, and painting the fallback of a boundary
4005
+ // whose content is right there would put a placeholder on screen for content we hold.
4006
+ if (allowWithoutBoundary || allowResponseSuspense) materializeStreamedSegments(doc)
4007
+ // A shell can carry markers for BOTH a loading.js boundary and the app's own
4008
+ // `<Suspense>`; prefer the loading.js one. Cached shells require that explicit route boundary,
4009
+ // while a live response paints any hole still pending when its shell commits.
4010
+ const marker =
4011
+ doc.querySelector(`pnext-suspense[data-pnext-suspense][${LOADING_DEPTH_ATTRIBUTE}]`) ??
4012
+ doc.querySelector('pnext-suspense[data-pnext-suspense]')
3687
4013
  const inline = marker ? null : inlineSuspenseRange(doc)
3688
4014
  const suspense = marker ?? inline?.anchor
3689
4015
  if (!suspense && !allowWithoutBoundary) return false
3690
- if (!loadingBoundaryChanges(marker, target)) return false
3691
- const { container, markerRange } = loadingShellTarget()
3692
- // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
3693
- // bar the instant this returns true), so the window route state must reflect the
3694
- // DESTINATION before any island reads useParams - otherwise usePathname and useParams
3695
- // diverge and a history entry captures the new URL with the OLD params.
3696
- const shellRoute = predictedRoute ?? documentRouteState(doc)
3697
- if (shellRoute) window.__PNEXT_ROUTE__ = shellRoute
4016
+ // Either wire form can carry the loading.js stamp; the hole is the inline form's marker.
4017
+ const boundary = marker ?? inline?.hole ?? null
4018
+ const loadingBoundary = isLoadingBoundaryMarker(boundary)
4019
+ if (!allowWithoutBoundary && !loadingBoundary && !allowResponseSuspense) {
4020
+ return false
4021
+ }
4022
+ if (!loadingBoundaryChanges(boundary, target)) return false
4023
+ const { container: slotContainer, markerRange } = loadingShellTarget()
4024
+ // `loadingShellTarget` degrades to <body> when the live document has no page slot - what a
4025
+ // CLIENT layout leaves behind, its slot markers consumed by the Preact render. Painting there
4026
+ // replaces the WHOLE live body with an inert copy: no preserved islands, no reused layout DOM.
4027
+ // When the incoming slot sits inside an island the live document also mounts, the paint IS
4028
+ // scopable after all - graft through that island (searchparams-reuse-loading reuses a
4029
+ // prefetched loading state under a client layout). Otherwise wait for the payload, which
4030
+ // grafts through `swapBody` with island preservation intact.
4031
+ const scopedOwner =
4032
+ !markerRange && slotContainer === document.body
4033
+ ? liveOwnerOfIncomingSlot(doc, boundary ?? suspense ?? null)
4034
+ : null
4035
+ if (!markerRange && slotContainer === document.body && !scopedOwner) return false
4036
+ const container = scopedOwner ?? slotContainer
3698
4037
  const fragment = document.createDocumentFragment()
3699
4038
  // The streamed document can already contain an ancestor layout that resolved before the
3700
4039
  // loading boundary. Keep that prefix when painting the fallback: replacing the target
@@ -3716,6 +4055,15 @@ function showLoadingShell(
3716
4055
  // nothing this paint could put on screen.
3717
4056
  null
3718
4057
  if (!sourceNodes) return false
4058
+ // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
4059
+ // bar the instant this returns true), so the window route state must reflect the
4060
+ // DESTINATION before any island reads useParams - otherwise usePathname and useParams
4061
+ // diverge and a history entry captures the new URL with the OLD params.
4062
+ const shellRoute = predictedRoute ?? documentRouteState(doc)
4063
+ if (shellRoute) window.__PNEXT_ROUTE__ = shellRoute
4064
+ // Past every bailout: this paint puts the destination on screen and commits the
4065
+ // navigation, so its sheets go in with it rather than waiting for the payload.
4066
+ void installStylesheets(doc)
3719
4067
  for (const node of sourceNodes) fragment.append(document.importNode(node, true))
3720
4068
  // A STATIC STAGE paints committed content, so its fallbacks land bare — the
3721
4069
  // wrappers are stripped from the COPY (the source doc keeps them, so the
@@ -3737,6 +4085,7 @@ function showLoadingShell(
3737
4085
  // useOffline(), say), and an unmounted island keeps its SSR value forever.
3738
4086
  // mountRoute is idempotent, so mounting on every paint is safe.
3739
4087
  mountPaintedIslands(doc)
4088
+ if (paintHold) document.body.append(paintHold)
3740
4089
  return true
3741
4090
  }
3742
4091
 
@@ -3749,6 +4098,25 @@ function liveIslandOwner(node: Element): LiveIslandRoot | null {
3749
4098
  return root?.__pnextLive ? root : null
3750
4099
  }
3751
4100
 
4101
+ /**
4102
+ * The MOUNTED island that owns the incoming document's page slot, when the live document
4103
+ * mounts the same one. It is the paint target a consumed slot leaves behind: the shell
4104
+ * re-renders through the island rather than over the whole body.
4105
+ */
4106
+ function liveOwnerOfIncomingSlot(doc: Document, boundary: Element | null): LiveIslandRoot | null {
4107
+ // A shell that suspended ABOVE its page slot ships no slot at all - the slot rides in the
4108
+ // resolved chunk. The boundary itself then names the island the paint belongs to.
4109
+ const incoming = (doc.getElementById('pnext-page') ?? boundary)?.closest(
4110
+ 'pnext-client[data-pnext-client]',
4111
+ )
4112
+ const id = incoming?.getAttribute('data-pnext-client')
4113
+ if (!id) return null
4114
+ const live: LiveIslandRoot | null = document.querySelector(
4115
+ `pnext-client[data-pnext-client="${CSS.escape(id)}"]`,
4116
+ )
4117
+ return live?.__pnextLive ? live : null
4118
+ }
4119
+
3752
4120
  /** `live`'s counterpart in an incoming document, matched on island id. */
3753
4121
  function incomingIslandFor(doc: Document, live: LiveIslandRoot): Element | null {
3754
4122
  const id = live.getAttribute('data-pnext-client')
@@ -3774,13 +4142,101 @@ function mountPaintedIslands(doc: Document) {
3774
4142
  * layout the live tree never rendered, a parallel-route slot beside the page) - those nodes
3775
4143
  * would be dropped. When the incoming stage carries such structure, take the real graft
3776
4144
  * path instead: `swapBody`, so root-layout DOM identity survives.
4145
+ *
4146
+ * Exported for the DOM-level unit test.
3777
4147
  */
3778
- function commitStaticStage(html: string, sequence: number, target: URL): boolean {
4148
+ export function commitStaticStage(
4149
+ html: string,
4150
+ sequence: number,
4151
+ target: URL,
4152
+ /**
4153
+ * Whether the server declared this a postponed shell. Under cacheComponents, Next treats this
4154
+ * as the segment-cache commit and may paint its application fallback. Ordinary apps still need
4155
+ * destination content beside the holes or an explicit loading.js boundary.
4156
+ */
4157
+ postponedShell = false,
4158
+ ): boolean {
3779
4159
  if (sequence !== navigationSequence) return false
4160
+ // cacheComponents' postponed stage is a real segment-cache commit, including
4161
+ // an application Suspense fallback such as Next's mismatching-prefetch shell.
4162
+ // Ordinary apps retain the stricter rule that prevented their root splash
4163
+ // from painting merely because a shell arrived.
4164
+ if (!staticStageIsRealContent(html, postponedShell && activityBfcacheEnabled())) return false
3780
4165
  if (paintStaticStageSubtree(html)) return true
3781
4166
  return showLoadingShell(html, sequence, target, undefined, true)
3782
4167
  }
3783
4168
 
4169
+ /**
4170
+ * True when a cached static stage is the destination's REAL content rather than an app shell
4171
+ * still waiting on its holes. `allowWithoutBoundary` exists for the fully prerendered stage,
4172
+ * which carries no boundary at all; a stage whose page content is NOTHING but an unresolved
4173
+ * `<Suspense>` fallback is a placeholder the dynamic payload replaces, and only a `loading.js`
4174
+ * boundary may paint that early - exactly the rule the loading-shell path applies.
4175
+ *
4176
+ * A partially static stage - real content BESIDE its holes - is the destination's prerendered
4177
+ * output, which Next paints as soon as it has it (segment-cache "serves cached static segments
4178
+ * instantly on the second navigation").
4179
+ */
4180
+ function staticStageIsRealContent(html: string, trustedPostponedShell = false): boolean {
4181
+ // Cheap reject: no boundary wire form in the markup means nothing is unresolved.
4182
+ if (!html.includes('data-pnext-suspense') && !html.includes('data-pnext-hole')) return true
4183
+ if (trustedPostponedShell) return true
4184
+ if (typeof DOMParser === 'undefined') return true
4185
+ const doc = new DOMParser().parseFromString(html, 'text/html')
4186
+ materializeClientIslandMarkers(doc)
4187
+ // Boundaries whose chunk already streamed resolve here; whatever marker survives is a hole
4188
+ // the dynamic stage still owes.
4189
+ materializeStreamedSegments(doc)
4190
+ // A hole stamped with the loading depth is a loading.js boundary, which may paint here for
4191
+ // the same reason its marker form may: it is the wait Next itself shows.
4192
+ const unresolved = [
4193
+ ...doc.querySelectorAll(`pnext-hole[data-pnext-hole]:not([${LOADING_DEPTH_ATTRIBUTE}])`),
4194
+ ...[...doc.querySelectorAll('pnext-suspense[data-pnext-suspense]')].filter(
4195
+ marker => !isLoadingBoundaryMarker(marker),
4196
+ ),
4197
+ ]
4198
+ return unresolved.length === 0 || stageCarriesContentBesideHoles(doc)
4199
+ }
4200
+
4201
+ /** Elements that carry no page content of their own: wire hosts and document plumbing. */
4202
+ const STAGE_STRUCTURAL_ELEMENTS = new Set([
4203
+ 'script',
4204
+ 'template',
4205
+ 'style',
4206
+ 'link',
4207
+ 'pnext-suspense',
4208
+ 'pnext-hole',
4209
+ ])
4210
+
4211
+ /**
4212
+ * True when the stage renders something of the destination's own OUTSIDE its unresolved
4213
+ * boundaries - the partially static (PPR) shape. A stage whose page slot holds only boundary
4214
+ * fallbacks is an app shell and keeps waiting.
4215
+ */
4216
+ function stageCarriesContentBesideHoles(doc: Document): boolean {
4217
+ const pageSlot = doc.getElementById('pnext-page')
4218
+ if (
4219
+ !pageSlot &&
4220
+ doc.querySelector(
4221
+ 'pnext-client :is(pnext-hole[data-pnext-hole],pnext-suspense[data-pnext-suspense])',
4222
+ )
4223
+ ) {
4224
+ // A boundary above the page slot postpones the client root that owns the page itself.
4225
+ // Wrapper/static-child markup around that hole is not an independently paintable PPR
4226
+ // segment; the page slot only materializes with the continuation.
4227
+ return false
4228
+ }
4229
+ const slot = pageSlot ?? doc.body
4230
+ if (!slot) return false
4231
+ for (const element of slot.querySelectorAll('*')) {
4232
+ if (STAGE_STRUCTURAL_ELEMENTS.has(element.localName)) continue
4233
+ if (element.closest('pnext-suspense[data-pnext-suspense], pnext-hole[data-pnext-hole]'))
4234
+ continue
4235
+ return true
4236
+ }
4237
+ return false
4238
+ }
4239
+
3784
4240
  /**
3785
4241
  * Replace every `<pnext-suspense>` fallback wrapper with the fallback itself. The renderer
3786
4242
  * wraps a boundary's fallback so the streaming runtime can promote the resolved chunk over
@@ -3807,6 +4263,7 @@ function unwrapSuspenseFallbacks(root: ParentNode): void {
3807
4263
  */
3808
4264
  export function paintStaticStageSubtree(html: string): boolean {
3809
4265
  if (typeof DOMParser === 'undefined') return false
4266
+ const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
3810
4267
  const doc = new DOMParser().parseFromString(html, 'text/html')
3811
4268
  if (!isPNextDocument(doc)) return false
3812
4269
  // Materialize BEFORE comparing structure: the renderer ships the page slot (and
@@ -3815,6 +4272,9 @@ export function paintStaticStageSubtree(html: string): boolean {
3815
4272
  materializeClientIslandMarkers(doc)
3816
4273
  materializeStreamedSegments(doc)
3817
4274
  if (!staticStageFrameGrows(doc)) return false
4275
+ // This paint COMMITS the navigation, so the destination's sheets belong on the document
4276
+ // now; the commit below re-reads them from its own doc and waits out these loads.
4277
+ void installStylesheets(doc)
3818
4278
  unwrapSuspenseFallbacks(doc)
3819
4279
  // A painted static stage IS a committed navigation (see showLoadingShell):
3820
4280
  // the window route state must describe the destination before any island
@@ -3837,6 +4297,7 @@ export function paintStaticStageSubtree(html: string): boolean {
3837
4297
  // against the tree painted here. `swapBody`'s own body-child reuse still applies.
3838
4298
  swapBody(doc)
3839
4299
  mountPaintedIslands(doc)
4300
+ if (paintHold) document.body.append(paintHold)
3840
4301
  return true
3841
4302
  }
3842
4303
 
@@ -3907,13 +4368,31 @@ function frameDescriptor(element: Element): string {
3907
4368
  /** Attribute the renderer stamps with a loading boundary's guarded URL depth. */
3908
4369
  const LOADING_DEPTH_ATTRIBUTE = 'data-pnext-loading-depth'
3909
4370
 
4371
+ /**
4372
+ * True when a shell's boundary is a `loading.js` one — the ONLY boundary Next may swap in
4373
+ * before the navigation payload lands. The renderer stamps the URL depth on exactly those
4374
+ * markers, so the attribute IS the signal: a plain `<Suspense>` the app wrote in a layout
4375
+ * (or in the page) carries none, and its fallback belongs to the incoming render, not to
4376
+ * the wait for it. Painting one over the departing page empties the body a whole request
4377
+ * early, where Next keeps the previous route interactive.
4378
+ *
4379
+ * The inline-suspense wire form (`<pnext-hole>`, rewritten from preact's stream comments)
4380
+ * gets the same stamp lifted onto it from the fallback's leading depth marker, so a
4381
+ * loading.js boundary proves itself in both wire forms; an app `<Suspense>` in either
4382
+ * carries nothing and never paints a fallback.
4383
+ */
4384
+ function isLoadingBoundaryMarker(marker: Element | null): boolean {
4385
+ return marker?.hasAttribute(LOADING_DEPTH_ATTRIBUTE) === true
4386
+ }
4387
+
3910
4388
  /**
3911
4389
  * True when the loading boundary carried by a shell owns a segment INSIDE the subtree this
3912
4390
  * navigation changes, so its fallback may paint. A loading.js boundary re-arms only when
3913
4391
  * the segment it directly guards changes; the server stamps each boundary's URL depth on
3914
4392
  * its marker, and when the live and target children paths diverge DEEPER than that the
3915
4393
  * boundary belongs to a preserved shared ancestor and painting it would flash a loading
3916
- * state Next never shows. Shells without the attribute keep painting.
4394
+ * state Next never shows. A STATIC STAGE paints committed content rather than a fallback,
4395
+ * so its (possibly absent) marker imposes no such gate.
3917
4396
  */
3918
4397
  function loadingBoundaryChanges(marker: Element | null, target: URL): boolean {
3919
4398
  const raw = marker?.getAttribute(LOADING_DEPTH_ATTRIBUTE)
@@ -3955,7 +4434,7 @@ async function pageForNavigation(
3955
4434
  * out. Unlike `onShell` this markup is not a loading fallback but the route's real static
3956
4435
  * content, so it paints with or without a boundary.
3957
4436
  */
3958
- onStaticStage?: (html: string) => void,
4437
+ onStaticStage?: (html: string, postponedShell?: boolean) => void,
3959
4438
  /** The segment-cache hit `softNavigate` already looked up for this URL. */
3960
4439
  segmentHit?: SegmentCacheHit | null,
3961
4440
  /**
@@ -3963,6 +4442,8 @@ async function pageForNavigation(
3963
4442
  * paint could swap the live one. Cached entries are matched against it, never the live one.
3964
4443
  */
3965
4444
  departureNavState: DocumentNavState = currentNavState(),
4445
+ /** The rendered origin route; unlike location this still names the departure on popstate. */
4446
+ departureUrl: string = locationKey(),
3966
4447
  /**
3967
4448
  * This navigation may ask for the `/_page` frame alone when the policy says the layout
3968
4449
  * chain is already in hand. Decided by `softNavigate` (it owns the departing URL/state)
@@ -3975,14 +4456,14 @@ async function pageForNavigation(
3975
4456
  // At most one static stage paints per navigation: an attached prefetch's
3976
4457
  // shell and a segment-cache hit are two views of the same content.
3977
4458
  let staticStagePainted = false
3978
- const paintStaticStage = (html: string) => {
4459
+ const paintStaticStage = (html: string, postponedShell = false) => {
3979
4460
  if (staticStagePainted) return
3980
4461
  staticStagePainted = true
3981
4462
  // A cached stage with parallel slots has dynamic continuations beside the page. Those cannot
3982
4463
  // be reconstructed from a page-only frame, so this navigation needs the whole target document.
3983
4464
  if (html.includes('data-pnext-slot=') || slotStateSensitive(departureNavState, html))
3984
4465
  pageFrame.eligible = false
3985
- onStaticStage?.(html)
4466
+ onStaticStage?.(html, postponedShell)
3986
4467
  }
3987
4468
  const cached = prefetchEntriesForNavigation(key).find(
3988
4469
  entry =>
@@ -4014,7 +4495,9 @@ async function pageForNavigation(
4014
4495
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
4015
4496
  // the navigation issued no duplicate fetch, so paint the static stage it landed and let
4016
4497
  // the dynamic stage stream in below.
4017
- if (page?.shellOnly) paintStaticStage(page.html)
4498
+ // A shell-only prefetch IS the server's postponed shell for this URL (its headers are what
4499
+ // `shellOnly` reads), so it paints like the PPR stage it is.
4500
+ if (page?.shellOnly) paintStaticStage(page.html, true)
4018
4501
  if (!page) {
4019
4502
  for (const full of [true, false]) {
4020
4503
  const cacheKey = prefetchCacheKey(key, full)
@@ -4054,7 +4537,8 @@ async function pageForNavigation(
4054
4537
  // Partial paint (Segment-M2 fix-forward 1): a cached static segment that may
4055
4538
  // NOT commit on its own still paints now, so the route's static content is on
4056
4539
  // screen while the dynamic stage below streams the remainder in.
4057
- if (segmentHit && !segmentHit.networkFree) paintStaticStage(segmentHit.html)
4540
+ if (segmentHit && !segmentHit.networkFree)
4541
+ paintStaticStage(segmentHit.html, segmentHit.postponedShell === true)
4058
4542
  // Per-segment navigation: `/_page` alone, composed with the cached layout.
4059
4543
  // Null (policy declined, miss, anything unexpected) falls through to the
4060
4544
  // whole-document fetch below unchanged.
@@ -4062,12 +4546,14 @@ async function pageForNavigation(
4062
4546
  ? await fetchPageFrameNavigation(url, {
4063
4547
  navState: options.navState ?? departureNavState,
4064
4548
  sameUrl: pageFrame.sameUrl,
4549
+ fromUrl: departureUrl,
4065
4550
  }).catch(() => null)
4066
4551
  : null
4067
4552
  const page =
4068
4553
  framed ??
4069
4554
  (await fetchPage(url.href, {
4070
4555
  navState: options.navState ?? departureNavState,
4556
+ fromUrl: departureUrl,
4071
4557
  onShell,
4072
4558
  }).catch(() => null))
4073
4559
  // Visited-page seeding: the navigation response is as fresh as any prefetch —
@@ -4197,15 +4683,28 @@ function seedNavigationEntry(
4197
4683
  }
4198
4684
 
4199
4685
  /**
4200
- * The document's own PRE-HYDRATION shell, stashed by the streaming runtime at the first
4201
- * chunk boundary. `captureHardLoad` serializes the LIVE DOM at the load event - after every
4686
+ * The document's own PRE-HYDRATION shell, stashed by the document bootstrap before island
4687
+ * hydration (or by the streaming runtime if a continuation promotes first). `captureHardLoad`
4688
+ * otherwise sees the LIVE DOM at the load event - after every
4202
4689
  * streamed chunk has been promoted over its fallback - so a PPR document reads back as
4203
- * settled and `sliceShell` finds no cut. The stash exists ONLY for a document that actually
4204
- * streamed a continuation, which is exactly the case the slice can no longer detect.
4690
+ * settled and `sliceShell` finds no cut.
4205
4691
  */
4206
4692
  function preHydrationShell(): string | undefined {
4207
- const stashed = (window as { __PNEXT_SHELL_HTML__?: unknown }).__PNEXT_SHELL_HTML__
4208
- return typeof stashed === 'string' && stashed.length > 0 ? stashed : undefined
4693
+ const stashed = (window as { __PNEXT_SHELL_HTML__?: string }).__PNEXT_SHELL_HTML__
4694
+ // If a promotion script won the capture race, one or more dynamic continuations already sit in
4695
+ // the markup. Those carry the loaded URL's resolved content, so anything replaying this stage -
4696
+ // a sibling param reusing it across an empty vary set - would paint that URL's data. Drop any
4697
+ // carriers; the document-bootstrap zero-stream case simply has none.
4698
+ return stashed && stripStreamChunks(stashed)
4699
+ }
4700
+
4701
+ /** `html` without the hidden `<div data-pnext-stream>` carriers of its streamed continuations. */
4702
+ function stripStreamChunks(html: string): string {
4703
+ const doc = new DOMParser().parseFromString(html, 'text/html')
4704
+ const chunks = doc.querySelectorAll('div[hidden][data-pnext-stream]')
4705
+ if (!chunks.length) return html
4706
+ for (const chunk of chunks) chunk.remove()
4707
+ return `<!doctype html>${doc.documentElement.outerHTML}`
4209
4708
  }
4210
4709
 
4211
4710
  /**
@@ -4252,12 +4751,14 @@ function recordNavigationSegment(
4252
4751
  if (hardLoad && runtimeDocument) return
4253
4752
  const hint = documentStaticHintFromHtml(page.html)
4254
4753
  const shell = staticStage ?? sliceShell(page.html, page.shellOnly === true)
4255
- // A HARD LOAD whose stashed pre-hydration stage came out of a BAKED SHELL is a prerender,
4256
- // and the server published the shell's vary set alongside the flag, so the entry keys
4257
- // across the route's params. Both halves are required: the flag alone would promote a
4258
- // sliced dynamic stream, the stash alone cannot prove the bytes are baked. The entry stays
4259
- // INCOMPLETE, so a navigation onto it still fetches its own dynamic remainder.
4260
- const bakedStaticStage = staticStage !== undefined && hint?.staticStage === true
4754
+ // A document resumed from a BAKED SHELL is a prerender, and the server published that
4755
+ // shell's vary set alongside the flag, so the entry keys across the route's params. Both
4756
+ // halves are required: the flag alone (with no stage in hand) proves nothing, and a stage
4757
+ // without it is a sliced dynamic stream. The stage is whichever the router holds - a hard
4758
+ // load's stashed pre-hydration prefix, or the slice of a streamed navigation response,
4759
+ // which cuts at exactly the boundary the server postponed at. The entry stays INCOMPLETE,
4760
+ // so a navigation onto it still fetches its own dynamic remainder.
4761
+ const bakedStaticStage = (staticStage ?? shell) !== null && hint?.staticStage === true
4261
4762
  const isStatic = page.segmentPrerendered === true || hint?.isStatic === true || bakedStaticStage
4262
4763
  if (shell === null && !isStatic) return
4263
4764
  const staleTimeMs = prefetchStaleTimeMs({
@@ -4289,6 +4790,7 @@ function recordNavigationSegment(
4289
4790
  complete: shell === null,
4290
4791
  static: isStatic,
4291
4792
  runtime: false,
4793
+ postponedShell: bakedStaticStage,
4292
4794
  })
4293
4795
  }
4294
4796
 
@@ -4384,8 +4886,14 @@ export function navStateKey(state: DocumentNavState): string {
4384
4886
  let navigationSequence = 0
4385
4887
  let suppressImmediateRefresh = false
4386
4888
 
4889
+ /** Current navigation generation, exposed so DOM-level paint tests retain the stale-work guard. */
4890
+ export function currentNavigationSequence(): number {
4891
+ return navigationSequence
4892
+ }
4893
+
4387
4894
  export async function softNavigate(href: string, options: SoftNavigateOptions = {}) {
4388
4895
  emitNavigationStart()
4896
+ const departingEntrySrc = entryScriptSrc(document)
4389
4897
  // The entry being left (on popstate, history already points at the target,
4390
4898
  // so the live document's entry id is the tracked active one).
4391
4899
  const departingBfcacheId = options.pop ? activeBfcacheId : (historyBfcacheId() ?? activeBfcacheId)
@@ -4401,27 +4909,50 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4401
4909
  // subtree (commitStaticStage) swaps the live `#__PNEXT_NAV_STATE__` for the
4402
4910
  // destination's, and reading it after that would report the incoming path as
4403
4911
  // the previous one (mis-classifying the nav as slot-only, killing the scroll).
4404
- const previousChildrenPath = currentNavState().children
4405
4912
  // The DEPARTING parallel-route state, likewise read before anything can paint. Every "may
4406
4913
  // this cached entry serve this navigation?" question is asked about the state the navigation
4407
4914
  // STARTED from. An optimistic loading-shell paint swaps the live `#__PNEXT_NAV_STATE__` for
4408
4915
  // the destination's, so re-reading after it would compare the navigation's own prefetch
4409
4916
  // against the target's state and reject it - re-fetching a route it had already prefetched.
4410
4917
  const departureNavState = currentNavState()
4918
+ const previousChildrenPath = departureNavState.children
4919
+ // `next-url` names the children tree the current document actually renders,
4920
+ // which can differ from the browser URL after middleware rewrites. Keep this
4921
+ // separate from `departingRouteKey`: the latter is intentionally the visible
4922
+ // pathname+search used by history/document caches.
4411
4923
  // Back/forward cache (Next's bfcache): the route being left, keyed by
4412
4924
  // pathname+search. On popstate `location` already points at the target, so
4413
4925
  // the departing route is the last committed one (`routerState.activeRouteKey`); on a
4414
4926
  // link/push nav it is simply the current location. Its stateful island roots
4415
4927
  // are stashed under this key below so returning to it (via link OR history
4416
4928
  // traversal) restores React state.
4417
- const departingRouteKey = options.pop
4418
- ? routerState.activeRouteKey
4419
- : bfRouteKey(location.pathname, location.search)
4929
+ const departingRouteKey = options.pop ? routerState.activeRouteKey : locationKey()
4930
+ const departureUrl = departureNavState.hostRender
4931
+ ? departingRouteKey
4932
+ : previousChildrenPath! + (departureNavState.childrenSearch ?? previousSearch)
4420
4933
  const url = resolveSoftUrl(href)
4421
4934
  if (!url) {
4422
4935
  hardNavigate(href, options.replace || options.pop)
4423
4936
  return
4424
4937
  }
4938
+ // Resolve a traversal's document in the same place as every other navigation source. popstate
4939
+ // supplies only the target history state; this chooser owns the decision between that entry's
4940
+ // immutable restore source and the normal cache/fetch path. `cachedPage` remains an explicit
4941
+ // injection point for focused DOM tests and callers that already hold a completed source.
4942
+ const entryRestorePage = options.pop
4943
+ ? entryDocCache.get(historyState().__pnextEntry as string)
4944
+ : undefined
4945
+ // A hard-loaded streaming document is cached from its immutable pre-hydration source. When that
4946
+ // source contains a boundary whose continuation had not parsed yet, it is a static/loading stage,
4947
+ // not a complete history document. Committing it on pop would bypass pageForNavigation entirely,
4948
+ // so no request could ever deliver the missing continuation and the entry would remain loading.
4949
+ // Keep explicit cachedPage injections unchanged for focused callers, but make an incomplete
4950
+ // entry-bound source fall through to the ordinary pop fetch.
4951
+ const restorePage =
4952
+ options.cachedPage ??
4953
+ (entryRestorePage && !streamHasPendingHole(entryRestorePage.html)
4954
+ ? entryRestorePage
4955
+ : undefined)
4425
4956
  // Client-instrumentation transition hook (Next's onRouterTransitionStart).
4426
4957
  ;(
4427
4958
  window as { __PNEXT_ON_ROUTER_TRANSITION_START__?: (href: string, kind: string) => void }
@@ -4465,6 +4996,11 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4465
4996
  // Snapshot plain root-layout DOM before a cached/streamed shell can detach it. The final swap
4466
4997
  // reconciles onto these nodes so server layouts keep the same identity Next's root reconciler does.
4467
4998
  const departingReusableBody = reusableBodyChildren()
4999
+ // Capture the complete outgoing screen before any loading/static stage can replace it. The
5000
+ // resolved client-root commit uses this copy only while its first complete paint settles.
5001
+ const paintHoldScrollTop =
5002
+ document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5003
+ const paintHold = createNavigationPaintHold()
4468
5004
  // Painting a prefetched loading shell IS a committed navigation, so push the requested URL
4469
5005
  // here and let usePathname() and the address bar reflect the destination while the fetch is
4470
5006
  // still in flight; the final commit replaces this entry with the resolved one (or, on a
@@ -4475,6 +5011,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4475
5011
  // shallow same-entry move in onPopState and get dropped, stranding the UI on the
4476
5012
  // half-committed target. The commit below reuses the id.
4477
5013
  let optimisticEntryId: string | undefined
5014
+ let optimisticBfcacheId: string | undefined
4478
5015
  // `silent` moves the address bar without broadcasting: used before the tree is painted, where a
4479
5016
  // location broadcast would render the destination URL against the departing route's params.
4480
5017
  const pushOptimisticUrl = (silent = false) => {
@@ -4482,7 +5019,21 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4482
5019
  if (url.pathname === location.pathname && url.search === location.search) return
4483
5020
  optimisticallyPushed = true
4484
5021
  optimisticEntryId = routerState.renderedEntryId = newEntryId()
4485
- const shellState = { ...historyState(), __pnextEntry: optimisticEntryId }
5022
+ // The loading/static stage publishes the destination URL immediately. Give
5023
+ // that render the destination's state identity too; otherwise useRouter
5024
+ // observes the new pathname with the departing bfcacheId, and the final
5025
+ // same-URL broadcast cannot make a keyed leaf reset.
5026
+ optimisticBfcacheId = nextBfcacheIdForNavigation(url, previousPathname)
5027
+ // A PAINTED optimistic entry's document is what is on screen from here on; keep the
5028
+ // live-entry pointer in step, or a traversal in this window snapshots the DESTINATION's
5029
+ // DOM under the departing entry's id and destroys that entry's form-state capture. The
5030
+ // silent (pre-paint) push leaves the pointer alone — the departing DOM is still live.
5031
+ if (!silent) activeBfcacheId = optimisticBfcacheId
5032
+ const shellState = {
5033
+ ...historyState(),
5034
+ __pnextEntry: optimisticEntryId,
5035
+ [HISTORY_BFCACHE_ID_KEY]: optimisticBfcacheId,
5036
+ }
4486
5037
  const move = () => {
4487
5038
  if (options.replace) history.replaceState(shellState, '', url.href)
4488
5039
  else history.pushState(shellState, '', url.href)
@@ -4505,12 +5056,19 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4505
5056
  // server-side, and re-painting would replace a committed loading state with a second,
4506
5057
  // different one. A segment's loading fallback commits ONCE per navigation.
4507
5058
  let cachedStagePainted = false
5059
+ const devSoftNavigation = isDevDocument()
4508
5060
  const onShell =
4509
- options.cachedPage || options.pop || refreshLike
5061
+ restorePage || options.pop || refreshLike
4510
5062
  ? undefined
4511
5063
  : (shellHtml: string) => {
4512
5064
  if (cachedStagePainted) return
4513
- if (!showLoadingShell(shellHtml, sequence, url)) return
5065
+ if (devSoftNavigation) return
5066
+ // In dev, compilation can suspend an application boundary even though the route's real
5067
+ // content is otherwise ready. Keep the departing page until that compile-only hole is
5068
+ // resolved; an explicit loading.js boundary remains paintable through the ordinary
5069
+ // loading-boundary path. Production retains streamed application-Suspense behavior.
5070
+ const devNavigation = shellHtml.includes('data-pnext-dev')
5071
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, !devNavigation)) return
4514
5072
  // A loading boundary is a committed navigation state.
4515
5073
  pushOptimisticUrl()
4516
5074
  scheduleNavigationScroll(url, options)
@@ -4519,7 +5077,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4519
5077
  // whether the generic loading shell should paint (a real cached static segment is strictly
4520
5078
  // better) and, in pageForNavigation, whether the navigation commits network-free.
4521
5079
  const segmentHit =
4522
- options.cachedPage || options.pop || refreshLike || slotStateSensitive(currentNavState())
5080
+ restorePage || options.pop || refreshLike || slotStateSensitive(currentNavState())
4523
5081
  ? null
4524
5082
  : (segmentCachePolicy?.take({
4525
5083
  pathname: url.pathname,
@@ -4529,8 +5087,9 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4529
5087
  // Paint a cached STATIC STAGE — the route's own content, not a fallback — and
4530
5088
  // treat it as a committed navigation exactly like a loading shell.
4531
5089
  const onStaticStage = onShell
4532
- ? (html: string) => {
4533
- if (!commitStaticStage(html, sequence, url)) return
5090
+ ? (html: string, postponedShell = false) => {
5091
+ if (devSoftNavigation) return
5092
+ if (!commitStaticStage(html, sequence, url, postponedShell)) return
4534
5093
  cachedStagePainted = true
4535
5094
  pushOptimisticUrl()
4536
5095
  scheduleNavigationScroll(url, options)
@@ -4544,6 +5103,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4544
5103
  // paint a sibling's layout while the destination layout is still on the wire.
4545
5104
  if (
4546
5105
  onShell &&
5106
+ !devSoftNavigation &&
4547
5107
  !refreshLike &&
4548
5108
  !segmentHit &&
4549
5109
  !segmentCachePolicy?.needsLayoutFrameOnly?.({
@@ -4587,12 +5147,12 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4587
5147
  // The document is still valid data for its own route, so it stays in the bfcache instead of
4588
5148
  // dying with the navigation. Never for a cached entry: re-storing one resets its commit time.
4589
5149
  const abandonFetchedPage = (fetched: PrefetchedPage | null | undefined) => {
4590
- if (!fetched || options.cachedPage) return
5150
+ if (!fetched || restorePage) return
4591
5151
  const settled = new URL(fetched.finalUrl, location.href)
4592
5152
  storeBfDoc(bfRouteKey(settled.pathname, settled.search), fetched)
4593
5153
  }
4594
5154
  let page =
4595
- options.cachedPage ??
5155
+ restorePage ??
4596
5156
  (await pageForNavigation(
4597
5157
  url,
4598
5158
  { ...options, refreshLike },
@@ -4600,6 +5160,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4600
5160
  onStaticStage,
4601
5161
  segmentHit,
4602
5162
  departureNavState,
5163
+ departureUrl,
4603
5164
  pageFrame,
4604
5165
  ))
4605
5166
  if (sequence !== navigationSequence) return abandonFetchedPage(page)
@@ -4632,7 +5193,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4632
5193
  // refetch with the full-render header. A cached history entry has no server to refetch from
4633
5194
  // as far as this navigation is concerned - the document IS the entry's own render.
4634
5195
  if (
4635
- !options.cachedPage &&
5196
+ !restorePage &&
4636
5197
  skippedSegmentsUngraftable(
4637
5198
  doc,
4638
5199
  options.freshSegments || cachedStagePainted || (refreshLike && !options.pageRefresh),
@@ -4640,6 +5201,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4640
5201
  ) {
4641
5202
  const fullPage = await fetchPage(url.href, {
4642
5203
  navState: options.navState,
5204
+ fromUrl: departureUrl,
4643
5205
  fullRender: true,
4644
5206
  }).catch(() => null)
4645
5207
  if (sequence !== navigationSequence) return abandonFetchedPage(fullPage ?? page)
@@ -4675,14 +5237,33 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4675
5237
  // then paints styled content immediately instead of flashing unstyled HTML.
4676
5238
  const entrySrc = entryScriptSrc(doc)
4677
5239
  warmEntryChunks(doc)
4678
- const [entryModule] = await Promise.all([
4679
- entrySrc ? importEntry(entrySrc) : Promise.resolve(null),
4680
- addStylesheets(doc),
4681
- ])
4682
- if (sequence !== navigationSequence) return abandonFetchedPage(page)
5240
+ // A restored traversal with warm modules and settled stylesheets commits in the popstate
5241
+ // task itself: the browser (and Next's in-memory router) treat back/forward as synchronous,
5242
+ // so a reader immediately after history.back() must observe the target document. Awaiting
5243
+ // here — even already-resolved promises — pushes the swap at least a task later and loses
5244
+ // that footrace. Everything upstream of this point is synchronous when restorePage is set.
5245
+ const pendingStylesheets = installStylesheets(doc)
5246
+ let entryModule = entrySrc ? (entryModuleCache.get(entryModuleHref(entrySrc)) ?? null) : null
5247
+ let departingEntryModule = departingEntrySrc
5248
+ ? (entryModuleCache.get(entryModuleHref(departingEntrySrc)) ?? null)
5249
+ : null
5250
+ const warmPop =
5251
+ options.pop &&
5252
+ Boolean(restorePage) &&
5253
+ pendingStylesheets.length === 0 &&
5254
+ (entryModule !== null || !entrySrc) &&
5255
+ (departingEntryModule !== null || !departingEntrySrc)
5256
+ if (!warmPop) {
5257
+ ;[entryModule, departingEntryModule] = await Promise.all([
5258
+ entrySrc ? importEntry(entrySrc) : Promise.resolve(null),
5259
+ departingEntrySrc ? importEntry(departingEntrySrc) : Promise.resolve(null),
5260
+ Promise.all(pendingStylesheets),
5261
+ ])
5262
+ if (sequence !== navigationSequence) return abandonFetchedPage(page)
5263
+ }
4683
5264
 
4684
5265
  if (!options.pop) {
4685
- const bfcacheId = nextBfcacheIdForNavigation(targetUrl, previousPathname)
5266
+ const bfcacheId = optimisticBfcacheId ?? nextBfcacheIdForNavigation(targetUrl, previousPathname)
4686
5267
  const entryState = {
4687
5268
  ...historyState(),
4688
5269
  // The shell push already minted this entry's id; keep it so the resolved
@@ -4708,11 +5289,27 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4708
5289
  else history.pushState(entryState, '', committedHref)
4709
5290
  }
4710
5291
 
5292
+ // Install the final document's route snapshot before any preserved island is reconciled.
5293
+ // A full popstate intentionally does not publish its address-bar change while the departing
5294
+ // tree is still live; this snapshot keeps useParams paired with the URL when mountRoute renders
5295
+ // that island for the committed tree. Forward commits need the same ordering when no loading or
5296
+ // static stage painted an earlier destination snapshot.
5297
+ // Compare against the departing route before publishing the incoming route
5298
+ // state. Once window.__PNEXT_ROUTE__ is replaced, routeParamBoundaryChanged
5299
+ // would compare the destination with itself and hide every param transition.
5300
+ const parallelSlotsChanged = navSlotsChanged(doc)
5301
+ const remountPageIslands =
5302
+ (options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc)
5303
+ const committedRoute = documentRouteState(doc)
5304
+ if (committedRoute) window.__PNEXT_ROUTE__ = committedRoute
5305
+
4711
5306
  // Template semantics: a client template island REMOUNTS (fresh state) when
4712
5307
  // the segment it wraps navigates to a different path, and is preserved like
4713
5308
  // a layout island otherwise (refresh, search-param-only and slot-only navs).
4714
5309
  const remountTemplates = docNavStateChildren(doc, targetUrl.pathname) !== previousChildrenPath
4715
- const remountPageIslands = routeParamBoundaryChanged(doc)
5310
+ // Page effects are entry-scoped. In particular, an app's popstate-gated initializer must run
5311
+ // again on cached back; reattaching the old live page root skips that initializer. Different
5312
+ // children routes likewise mount fresh even when two pages happen to use the same island id.
4716
5313
  // Shared-layout state preservation: live island roots that render again in the incoming
4717
5314
  // document keep their DOM (and component state) across the swap. Server-segment
4718
5315
  // preservation keeps live layout DOM and refreshes only the page slot. Parallel-route slot
@@ -4728,10 +5325,30 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4728
5325
  options.freshSegments ||
4729
5326
  cachedStagePainted ||
4730
5327
  (refreshLike && !options.pageRefresh) ||
4731
- navSlotsChanged(doc)
5328
+ parallelSlotsChanged
4732
5329
  ? (clearSegmentPreserveTags(doc), [])
4733
5330
  : matchPreservedServerSegments(doc)
4734
5331
  const preservedIslands = matchPreservedIslands(doc, remountTemplates, remountPageIslands)
5332
+ // A missing live page marker alone is not enough: ordinary server layouts also consume their
5333
+ // marker after mount. The slotless lifecycle path is specifically for a preserved client-layout
5334
+ // island whose incoming placeholder owns the page marker that its live tree adopted.
5335
+ const slotlessClientRootLayout = hasSlotlessClientRootLayout(doc, preservedIslands)
5336
+ // A cacheComponents tree follows Next's Activity lifecycle: the inactive page
5337
+ // stays mounted. The explicit empty-page pass exists only for ordinary apps,
5338
+ // whose outgoing page must unmount while its DOM is still connected.
5339
+ // On a traversal, the browser has already fired popstate. A synthetic empty-page
5340
+ // render would remount layout effects against the departing URL and let them
5341
+ // consume that signal before the destination page can initialize from it. The
5342
+ // final mount still reconciles the outgoing page while its preserved shell is
5343
+ // connected. Forward navigations retain the explicit pass so their cleanup can
5344
+ // snapshot the departing page before the swap.
5345
+ const unmountSlotlessPage = slotlessClientRootLayout && !activityBfcacheEnabled() && !options.pop
5346
+ if (unmountSlotlessPage) {
5347
+ keySlotlessClientPage(
5348
+ doc,
5349
+ `${targetUrl.pathname}${targetUrl.search}|${historyBfcacheId() ?? ''}`,
5350
+ )
5351
+ }
4735
5352
  const preservedPage = matchPreservedClientPage(
4736
5353
  doc,
4737
5354
  (refreshLike || sameRouteQueryNav(doc)) && !options.remount,
@@ -4744,11 +5361,22 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4744
5361
  ...preservedIslands,
4745
5362
  ...preservedSegmentIslands(preservedSegments),
4746
5363
  ])
5364
+ // A client root layout can consume the page slot during hydration, leaving its route screens as
5365
+ // top-level island roots beside the preserved shell islands. Absence from #pnext-page must not
5366
+ // promote those unmatched screens into layout cache entries: they are page lifecycle owners and
5367
+ // must unmount while the live shell (and its scroll container) is still attached. The shared roots
5368
+ // are already identified above by matching an incoming placeholder and remain preserved.
4747
5369
  // Back/forward cache - stash BEFORE restore, so the fixed-size eviction sees the true entry
4748
5370
  // count (page 1 evicts on the 4th visit even though that same navigation restores page 2).
4749
5371
  // Keeping the departing roots alive and adding them to keptIslands makes the unmount below
4750
5372
  // skip them, so a later return restores their React state.
4751
- const departingRoots = departingLiveRoots.filter(root => !keptLiveIslands.has(root))
5373
+ const preserveInactiveTree = activityBfcacheEnabled()
5374
+ const departingRoots = preserveInactiveTree
5375
+ ? departingLiveRoots.filter(root => !keptLiveIslands.has(root))
5376
+ : []
5377
+ // Only shared-layout roots belong in pnext's live-root cache. Page roots must be unmounted by
5378
+ // the outgoing entry while still attached (so effect cleanups can read/save DOM state), then the
5379
+ // destination source mounts a fresh root after the swap.
4752
5380
  if (departingRouteKey && departingRouteKey !== targetRouteKey) {
4753
5381
  stashRouteRoots(departingRouteKey, departingRoots)
4754
5382
  }
@@ -4758,9 +5386,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4758
5386
  // cacheComponents (Next's link-navigation bfcache ships with the client segment cache); a
4759
5387
  // classic app remounts instead, so a re-revealed accordion starts closed again. History
4760
5388
  // traversal restores either way.
4761
- const bfcacheRestores = options.pop || segmentSchedulerEnabled()
4762
5389
  const restoredIslands =
4763
- refreshLike || !bfcacheRestores
5390
+ refreshLike || !preserveInactiveTree
4764
5391
  ? []
4765
5392
  : matchRouteCachedIslands(doc, targetRouteKey, preservedIslands.length, remountTemplates)
4766
5393
  const keptIslands = new Set<Element>([...keptLiveIslands, ...restoredIslands])
@@ -4769,68 +5396,157 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4769
5396
  // page slot is a marker-range proxy); the entry's unmount compares roots by
4770
5397
  // identity, so it rides the same set.
4771
5398
  if (preservedPage) keptIslands.add(preservedPage.root as unknown as Element)
5399
+ // Wake route-keyed layout children while the departing DOM is still attached. Their layout
5400
+ // cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
4772
5401
  window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
4773
- syncHeadMetadata(doc)
4774
- // Next reconciles the DOM in place, so a focused element in a retained layout keeps focus
4775
- // across a navigation. pnext's swap DETACHES preserved subtrees to graft them into the new
4776
- // body, and detaching blurs - remember the focused node so it can be refocused.
4777
- const focusedBeforeSwap = document.activeElement
4778
- swapBody(
4779
- doc,
4780
- [...preservedIslands, ...restoredIslands],
4781
- preservedSegments,
4782
- preservedPage,
4783
- departingReusableBody,
4784
- )
4785
- stylesheetReconciler?.(doc)
4786
- // The swapped document carries the render's parallel-route state; pin it
4787
- // (plus the document itself) to this history entry so back/forward restores
4788
- // what was actually shown, without a server round trip.
4789
- storeNavState()
4790
- cacheEntryDocument(page)
4791
- // The committed document is a shown-route snapshot: a later full prefetch of
4792
- // this route reads it from the bfcache instead of the network.
4793
- storeBfDoc(targetRouteKey, page)
4794
- // This route is now the committed one — the next navigation's departing key.
4795
- routerState.activeRouteKey = targetRouteKey
4796
-
4797
- await entryModule?.mountRoute?.()
4798
- if (sequence !== navigationSequence) return
4799
- restoreSwapFocus(focusedBeforeSwap)
4800
- if (!options.pop && departingBfcacheId && previousPathname !== targetUrl.pathname) {
4801
- restoreSharedLayoutFormState(departingBfcacheId, previousPathname, targetUrl.pathname)
5402
+ let focusedBeforeSwap: Element | null = null
5403
+ let navigationFocusTarget: HTMLElement | null = null
5404
+ try {
5405
+ if (unmountSlotlessPage && departingRouteKey && departingRouteKey !== targetRouteKey) {
5406
+ // Cover the lifecycle-only empty-page pass. This is the same outgoing screen clone used for
5407
+ // the final atomic commit, attached early enough that no empty intermediate frame can paint.
5408
+ attachNavigationPaintHold(paintHold, paintHoldScrollTop, sequence)
5409
+ await unmountSlotlessClientPage(
5410
+ doc,
5411
+ preservedIslands,
5412
+ departingEntryModule,
5413
+ departingRouteKey,
5414
+ )
5415
+ if (sequence !== navigationSequence) return
5416
+ }
5417
+ syncHeadMetadata(doc)
5418
+ // Next reconciles the DOM in place, so a focused element in a retained layout keeps focus
5419
+ // across a navigation. pnext's swap DETACHES preserved subtrees to graft them into the new
5420
+ // body, and detaching blurs - remember the focused node so it can be refocused.
5421
+ focusedBeforeSwap = document.activeElement
5422
+ swapBody(
5423
+ doc,
5424
+ [...preservedIslands, ...restoredIslands],
5425
+ preservedSegments,
5426
+ preservedPage,
5427
+ departingReusableBody,
5428
+ )
5429
+ attachNavigationPaintHold(paintHold, paintHoldScrollTop, sequence)
5430
+ // A client root layout adopts and dissolves the page-slot markers during
5431
+ // mount. Resolve Next's segment scroll target against the freshly swapped
5432
+ // document while those markers still identify the changed page.
5433
+ const slotOnlyNav =
5434
+ currentNavState().children === previousChildrenPath &&
5435
+ url.search === previousSearch &&
5436
+ // A changed pathname is only slot-only when the parallel-slot state
5437
+ // actually changed. This remains decidable when a body-owned client shell
5438
+ // has already removed the departing entry's route script.
5439
+ (targetUrl.pathname === previousPathname || parallelSlotsChanged) &&
5440
+ !remountPageIslands
5441
+ const navigationScrollOptions = {
5442
+ ...options,
5443
+ scroll:
5444
+ targetUrl.pathname !== previousPathname && !parallelSlotsChanged
5445
+ ? options.scroll
5446
+ : slotOnlyNav
5447
+ ? false
5448
+ : options.scroll,
5449
+ }
5450
+ // Resolve the changed segment while the freshly swapped document still has
5451
+ // its page markers. A body-owning client root dissolves those markers during
5452
+ // mount, after which the compat scroll walk can only see the broad shell.
5453
+ scheduleNavigationScroll(url, navigationScrollOptions)
5454
+ const focusedByNavigation = document.activeElement
5455
+ if (
5456
+ focusedByNavigation instanceof HTMLElement &&
5457
+ focusedByNavigation !== document.body &&
5458
+ focusedByNavigation !== document.documentElement &&
5459
+ focusedByNavigation !== focusedBeforeSwap
5460
+ ) {
5461
+ navigationFocusTarget = focusedByNavigation
5462
+ }
5463
+ stylesheetReconciler?.(doc)
5464
+ // Finish the stylesheet transaction in the same synchronous DOM turn as the body swap. A
5465
+ // selector waiter can observe the destination immediately after this stack unwinds; pruning in
5466
+ // the later post-mount phase let it catch the target node between its correct route sheet and a
5467
+ // stale asynchronous prune from the preceding navigation.
5468
+ if (!url.hash) pruneStylesheets(doc)
5469
+ // The swapped document carries the render's parallel-route state; pin it
5470
+ // (plus the document itself) to this history entry so back/forward restores
5471
+ // what was actually shown, without a server round trip.
5472
+ storeNavState()
5473
+ cacheEntryDocument(page)
5474
+ // The committed document is a shown-route snapshot: a later full prefetch of
5475
+ // this route reads it from the bfcache instead of the network.
5476
+ storeBfDoc(targetRouteKey, page)
5477
+ // This route is now the committed one — the next navigation's departing key.
5478
+ routerState.activeRouteKey = targetRouteKey
5479
+ pingVisiblePrefetchLinks()
5480
+ // Browser form restoration is part of the traversal itself: fill the swapped (raw,
5481
+ // pre-hydration) controls in the same synchronous turn as the swap, so a reader right after
5482
+ // history.back() sees the values. The post-effects pass below still handles controls a
5483
+ // mount rebuilds, and only ever fills empty ones.
5484
+ if (options.pop) restoreFormState(historyBfcacheId())
5485
+
5486
+ if (page.p) (window as typeof window & { __PNEXT_PROPS__?: unknown }).__PNEXT_PROPS__ = page.p
5487
+ await entryModule?.mountRoute?.()
5488
+ // The mount can recreate the controls the pre-mount fill above populated. Re-fill before
5489
+ // flushClientEffects' settling FRAME below — deferring past it shows a traversal with
5490
+ // empty controls for a full frame, which a reader right after history.back() observes.
5491
+ if (options.pop && sequence === navigationSequence) restoreFormState(historyBfcacheId())
5492
+ // Compat batches layout-effect disposal to the end of the Preact commit. Let that disposal run
5493
+ // before usePathname wakes the preserved PageTransition's destination effect (which resets the
5494
+ // shared shell scroll position). The old screen and its scroll container are still connected.
5495
+ await flushClientEffects()
5496
+ // Publish the committed URL while the paint hold still covers the new tree. Preserved client
5497
+ // layouts key their routed child on usePathname/useSearchParams; waking that store is what gives
5498
+ // the outgoing child a real cleanup and the destination a fresh initializer. Keeping the hold
5499
+ // through the following frame makes that keyed replacement atomic in dev as well as production.
5500
+ if (sequence === navigationSequence) {
5501
+ // Commit listeners run first: loaders may deliberately remain visible until the location
5502
+ // subscriber observes the matching tree (the same ordering as the former end-of-commit pair).
5503
+ emitNavigationCommit()
5504
+ emitLocationChange()
5505
+ // History traversal restores the popped entry's form state over the freshly mounted tree
5506
+ // (browser back/forward form restoration semantics). This runs BEFORE the paint hold's
5507
+ // settling frame below: mounting can recreate the controls the pre-mount fill populated,
5508
+ // and deferring the re-fill past the hold's rAF leaves a visibly empty window after the
5509
+ // traversal has committed.
5510
+ if (options.pop) {
5511
+ const targetBfcacheId = historyBfcacheId()
5512
+ restoreFormState(targetBfcacheId)
5513
+ if (targetBfcacheId) restoreFormStateWhenMounted(targetBfcacheId, sequence)
5514
+ }
5515
+ }
5516
+ } finally {
5517
+ await releaseNavigationPaintHold(paintHold)
4802
5518
  }
4803
- // History traversal restores the popped entry's form state over the freshly
4804
- // mounted tree (browser back/forward form restoration semantics).
5519
+ if (sequence !== navigationSequence) return
5520
+ // Mounting/location subscribers run after the initial pre-mount scroll action.
5521
+ // Reapply traversal coordinates once that work has settled; otherwise a page
5522
+ // effect can reset the restored position to zero after Back.
4805
5523
  if (options.pop) {
4806
- const targetBfcacheId = historyBfcacheId()
4807
- restoreFormState(targetBfcacheId)
4808
- if (targetBfcacheId) restoreFormStateWhenMounted(targetBfcacheId, sequence)
5524
+ await flushClientEffects()
5525
+ scheduleNavigationScroll(url, options)
5526
+ }
5527
+ restoreSwapFocus(focusedBeforeSwap, navigationFocusTarget)
5528
+ // Activity-preserved trees reconcile their keyed leaf against the new entry id
5529
+ // themselves. Replaying the departing DOM snapshot there would resurrect the
5530
+ // old leaf value after React correctly reset it; the fallback is only for the
5531
+ // ordinary remounting path where pnext has replaced layout-owned DOM.
5532
+ if (
5533
+ !preserveInactiveTree &&
5534
+ !options.pop &&
5535
+ departingBfcacheId &&
5536
+ previousPathname !== targetUrl.pathname
5537
+ ) {
5538
+ restoreSharedLayoutFormState(departingBfcacheId, previousPathname, targetUrl.pathname)
4809
5539
  }
4810
5540
  // Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
4811
5541
  // `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
4812
5542
  // still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
4813
5543
  // swap), so re-apply the departure snapshot as the controls appear - but only to the DOM
4814
5544
  // this swap replaced, never to a control a live island's own render owns (see
4815
- // islandOwnedControl).
4816
- else if (departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5545
+ // islandOwnedControl). The pop equivalent runs inside the commit above, ahead of the paint
5546
+ // hold's settling frame.
5547
+ if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
4817
5548
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
4818
5549
  }
4819
-
4820
- // A navigation that only changed parallel slots (same children path AND unchanged search
4821
- // params - modal patterns) keeps the scroll position. A same-path nav that changes the
4822
- // search params still scrolls to top.
4823
- const slotOnlyNav =
4824
- currentNavState().children === previousChildrenPath &&
4825
- url.search === previousSearch &&
4826
- // A nav that changes route params (same children-path template, different segment values
4827
- // under a shared layout) is NOT slot-only and scrolls to top. True slot-only navs keep the
4828
- // same route AND params.
4829
- !remountPageIslands
4830
- scheduleNavigationScroll(url, { ...options, scroll: slotOnlyNav ? false : options.scroll })
4831
- if (!url.hash) pruneStylesheets(doc)
4832
- emitNavigationCommit()
4833
- emitLocationChange()
4834
5550
  }
4835
5551
 
4836
5552
  function isBotUserAgent() {
@@ -4872,10 +5588,14 @@ const RESTORED_INPUT_TYPES = new Set([
4872
5588
  function formControls(): (HTMLInputElement | HTMLTextAreaElement)[] {
4873
5589
  return [
4874
5590
  ...document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input, textarea'),
4875
- ].filter(control =>
4876
- control instanceof HTMLTextAreaElement
4877
- ? true
4878
- : RESTORED_INPUT_TYPES.has(control.getAttribute('type')?.toLowerCase() ?? ''),
5591
+ ].filter(
5592
+ control =>
5593
+ // The navigation paint hold is a paint-only CLONE of the departing screen: its copied
5594
+ // controls would shift indices and trip the count guard during the exact window the
5595
+ // synchronous pop restore runs in.
5596
+ !control.closest('[data-pnext-navigation-paint-hold]') &&
5597
+ (control instanceof HTMLTextAreaElement ||
5598
+ RESTORED_INPUT_TYPES.has(control.getAttribute('type')?.toLowerCase() ?? '')),
4879
5599
  )
4880
5600
  }
4881
5601
 
@@ -4949,8 +5669,12 @@ function restoreSharedLayoutFormState(
4949
5669
  const target = targetPathname.split('/').filter(Boolean)
4950
5670
  for (const [index, control] of controls.entries()) {
4951
5671
  if (elementInPageSlot(control)) continue
5672
+ // Only controls rendered by the layout island itself belong to this
5673
+ // shared scope. A nested page island may sit inside that host after it
5674
+ // adopts the page slot, but its bfcacheId intentionally changes on a fresh
5675
+ // push and its keyed form must be allowed to reset.
4952
5676
  const raw = control
4953
- .closest('pnext-client[data-pnext-layout-segments]')
5677
+ .closest('pnext-client[data-pnext-client]')
4954
5678
  ?.getAttribute('data-pnext-layout-segments')
4955
5679
  if (!raw) continue
4956
5680
  const depth = (JSON.parse(raw) as { depth?: number }).depth
@@ -5095,7 +5819,7 @@ function onLinkIntent(event: Event) {
5095
5819
  // touches roots no earlier pass mounted) and re-sync the router's bookkeeping.
5096
5820
  function onPageShow(event: PageTransitionEvent) {
5097
5821
  if (!event.persisted) return
5098
- routerState.activeRouteKey = bfRouteKey(location.pathname, location.search)
5822
+ routerState.activeRouteKey = locationKey()
5099
5823
  const entryId = historyState().__pnextEntry
5100
5824
  if (typeof entryId === 'string') routerState.renderedEntryId = entryId
5101
5825
  // A mount claim (mountIslandOnce) left in flight by the freeze can never
@@ -5132,18 +5856,13 @@ export function onPopState() {
5132
5856
  // (what was on screen when the entry was active), not the current
5133
5857
  // document's: from the entry cache when possible, else by re-rendering
5134
5858
  // server-side from the recorded slot state.
5135
- const cachedPage = entryDocCache.get(entryId)
5136
5859
  const navState = state.__pnextNavState as DocumentNavState | undefined
5137
- // Broadcast the popped location SYNCHRONOUSLY: softNavigate resolves the
5138
- // document asynchronously, and a subscriber reading usePathname right after
5139
- // the traversal would otherwise still see the departing route. The popped
5140
- // entry's own recorded route state goes with it so useParams stays in step.
5141
- const poppedRoute = cachedPage ? routeStateFromHtml(cachedPage.html) : undefined
5142
- if (poppedRoute) window.__PNEXT_ROUTE__ = poppedRoute
5143
- emitLocationChange()
5144
- void softNavigate(location.href, { pop: true, navState, cachedPage }).catch(() =>
5145
- location.reload(),
5146
- )
5860
+ // Do not broadcast the address-bar change until softNavigate commits the popped document.
5861
+ // Persistent islands belong to the rendered tree, not to the speculative browser location:
5862
+ // publishing here can update their hook state while they are being detached, then make the
5863
+ // post-commit broadcast bail out as unchanged. softNavigate installs the cached document's route
5864
+ // snapshot before mountRoute and emits the location immediately after the navigation commit.
5865
+ void softNavigate(location.href, { pop: true, navState }).catch(() => location.reload())
5147
5866
  }
5148
5867
 
5149
5868
  const eagerLinks = new WeakSet<Element>()
@@ -5153,6 +5872,24 @@ const eagerLinks = new WeakSet<Element>()
5153
5872
  let visibleLinkObserver: IntersectionObserver | undefined
5154
5873
  let visibleLinkObserverDoc: Document | undefined
5155
5874
 
5875
+ /** Recompute visible Link prefetches against the newly committed URL/base tree. */
5876
+ export function pingVisiblePrefetchLinks() {
5877
+ for (const element of visiblePrefetchElements) {
5878
+ if (!element.isConnected) {
5879
+ visiblePrefetchElements.delete(element)
5880
+ prefetchedElements.delete(element)
5881
+ continue
5882
+ }
5883
+ const href = element.getAttribute('href')
5884
+ if (href) {
5885
+ void prefetchRoute(href, {
5886
+ element,
5887
+ full: isFullPrefetchLink(element),
5888
+ })
5889
+ }
5890
+ }
5891
+ }
5892
+
5156
5893
  // `load` links prefetch as soon as they appear; `visible` links when they
5157
5894
  // enter the viewport. Both arrive with the page or with later client renders,
5158
5895
  // so watch the whole document for additions.
@@ -5178,6 +5915,7 @@ function scanEagerPrefetchLinks(root: Element) {
5178
5915
  eagerLinks.add(link)
5179
5916
  if (mode === 'load') {
5180
5917
  const href = link.getAttribute('href')
5918
+ if (isElementVisible(link)) visiblePrefetchElements.add(link)
5181
5919
  if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
5182
5920
  continue
5183
5921
  }
@@ -5188,6 +5926,7 @@ function scanEagerPrefetchLinks(root: Element) {
5188
5926
  visibleLinkObserver ??= new IntersectionObserver(entries => {
5189
5927
  for (const entry of entries) {
5190
5928
  if (!entry.isIntersecting) {
5929
+ visiblePrefetchElements.delete(entry.target)
5191
5930
  // The link left the viewport (scrolled/hidden) — cancel its pending
5192
5931
  // prefetch task: queued work is dropped, an in-flight task stops
5193
5932
  // before its next phase, and the pending cache entry is evicted so
@@ -5196,6 +5935,7 @@ function scanEagerPrefetchLinks(root: Element) {
5196
5935
  if (task) cancelPrefetchTask(task)
5197
5936
  continue
5198
5937
  }
5938
+ visiblePrefetchElements.add(entry.target)
5199
5939
  const href = entry.target.getAttribute('href')
5200
5940
  if (href)
5201
5941
  void prefetchRoute(href, {
@@ -5206,6 +5946,7 @@ function scanEagerPrefetchLinks(root: Element) {
5206
5946
  })
5207
5947
  visibleLinkObserver.observe(link)
5208
5948
  if (isElementVisible(link)) {
5949
+ visiblePrefetchElements.add(link)
5209
5950
  const href = link.getAttribute('href')
5210
5951
  if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
5211
5952
  continue
@@ -5245,16 +5986,46 @@ export function installRouterFull() {
5245
5986
  // Seed the hard-loaded entry's ids BEFORE any island mounts — the hub already
5246
5987
  // did that (see ./index.ts installRouter); this tier picks up from there.
5247
5988
  storeNavState()
5248
- // A streaming route is still showing its shell when the async entry module first runs - the
5249
- // deferred `<template data-pnext-stream>` chunks have not been grafted in yet. Snapshotting
5250
- // `outerHTML` now would cache the LOADING SHELL as the resolved document, so a later
5251
- // navigation back commits that stale shell. Defer the snapshot+seed until load completes.
5989
+ // Next's initial RSC payload seeds the segment cache before request params resolve. PNext's hard
5990
+ // HTML load carries only the resolved continuation, so obtain the prerendered current-route stage
5991
+ // now and keep it as the cacheComponents segment seed. Default apps do no extra work.
5992
+ if (activityBfcacheEnabled()) void prefetchRoute(location.href, { currentUrl: true })
5993
+ // Bind every delayed hard-load operation to the document and history entry that installed this
5994
+ // runtime. `load` can fire after a fast soft navigation has already committed; consulting the
5995
+ // then-current location/state would file the original source under the destination entry key.
5996
+ const hardLoadRouteKey = locationKey()
5997
+ const hardLoadPathname = location.pathname
5998
+ let hardLoadHtml =
5999
+ (window as { __PNEXT_SHELL_HTML__?: string }).__PNEXT_SHELL_HTML__ ||
6000
+ `<!doctype html>${document.documentElement.outerHTML}`
6001
+ // The shell bootstrap can run before these settled, URL-specific scripts parse. A traversal must
6002
+ // restore them with the immutable entry source; otherwise a whole-page client component remounts
6003
+ // against the route/props globals of the page just left (for example Back to `[id]=1` renders 2).
6004
+ if (currentNavState().children !== hardLoadPathname)
6005
+ hardLoadHtml += document.getElementById('__PNEXT_NAV_STATE__')?.outerHTML ?? ''
6006
+ const hardLoadEntrySrc = entryScriptSrc(document)
6007
+ if (hardLoadEntrySrc) hardLoadHtml += `<script type="module" src="${hardLoadEntrySrc}"></script>`
6008
+ const hardLoadRestoreSource: PrefetchedPage = {
6009
+ html: hardLoadHtml,
6010
+ finalUrl: location.href,
6011
+ ok: true,
6012
+ p: (window as typeof window & { __PNEXT_PROPS__?: unknown }).__PNEXT_PROPS__,
6013
+ }
6014
+ const hardLoadStaticStage = documentStaticHintFromHtml(hardLoadRestoreSource.html)?.isStatic
6015
+ ? undefined
6016
+ : preHydrationShell()
6017
+ // Entry-document identity is needed as soon as navigation can start, not at window.load.
6018
+ cacheEntryDocument(hardLoadRestoreSource)
6019
+ const hardLoadEntryId = historyState().__pnextEntry
6020
+ // The source snapshot is already entry-bound above. Defer only navigation-cache seeding until
6021
+ // load, when the original response has completed its browser lifecycle.
5252
6022
  const captureHardLoad = () => {
5253
- const hardLoadedPage: PrefetchedPage = {
5254
- html: `<!doctype html>${document.documentElement.outerHTML}`,
5255
- finalUrl: location.href,
5256
- ok: true,
5257
- }
6023
+ // A fast navigation can commit before the original window load event. The live DOM then belongs
6024
+ // to another entry and must not be filed as this hard load's settled segment payload.
6025
+ if (historyState().__pnextEntry !== hardLoadEntryId) return
6026
+ const settledHtml = `<!doctype html>${document.documentElement.outerHTML}`
6027
+ const hardLoadHint = documentStaticHintFromHtml(settledHtml)
6028
+ const hardLoadRuntimePrefetch = htmlRuntimePrefetch(settledHtml)
5258
6029
  // A hard HTML load carries no `x-nextjs-stale-time` response header, so
5259
6030
  // seedNavigationEntry would fall to the dynamic default (0) and a fully
5260
6031
  // static page gets re-requested on the next navigation. The document inlines
@@ -5262,31 +6033,46 @@ export function installRouterFull() {
5262
6033
  // when the route is static, seed the TRUE static window (its own `cacheLife`
5263
6034
  // stale seconds, or the configured static default) and mark it prerendered so
5264
6035
  // no `_rsc` refetch fires while the window is fresh.
5265
- const hint = documentStaticHint()
5266
- if (hint?.isStatic) {
5267
- hardLoadedPage.staleTimeSeconds = hint.staleTime ?? shellStaleTimeMs() / 1000
5268
- hardLoadedPage.segmentPrerendered = true
6036
+ const navigationSeedPage: PrefetchedPage = {
6037
+ ...hardLoadRestoreSource,
6038
+ html: settledHtml,
6039
+ }
6040
+ if (hardLoadHint?.isStatic) {
6041
+ navigationSeedPage.staleTimeSeconds = hardLoadHint.staleTime ?? shellStaleTimeMs() / 1000
6042
+ navigationSeedPage.segmentPrerendered = true
5269
6043
  }
5270
- cacheEntryDocument(hardLoadedPage)
6044
+ // Keep the entry-bound history document as immutable server source: ordinary
6045
+ // (non-cacheComponents) traversals must remount it, rather than restoring a
6046
+ // hydrated tree whose effect cleanup may already have mutated application
6047
+ // state. The navigation-data seed has a different contract. Once the hard
6048
+ // load settles, its resolved page data is fresh for staleTimes.dynamic and a
6049
+ // later Link back to this URL must be able to reuse it.
5271
6050
  // Visited-page seeding: the hard load is as fresh as a fetch — an immediate
5272
6051
  // soft navigation back to this URL within the dynamic window reuses it.
5273
6052
  seedNavigationEntry(
5274
- location.pathname + location.search,
5275
- location.pathname,
5276
- hardLoadedPage,
6053
+ hardLoadRouteKey,
6054
+ hardLoadPathname,
6055
+ navigationSeedPage,
5277
6056
  undefined,
5278
6057
  // A streaming document's static stage is unrecoverable from the settled
5279
6058
  // DOM; the pre-promotion stash is the only faithful copy.
5280
- hint?.isStatic ? undefined : preHydrationShell(),
6059
+ hardLoadStaticStage,
5281
6060
  true,
5282
6061
  )
5283
- storeBfDoc(location.pathname + location.search, hardLoadedPage)
6062
+ storeBfDoc(hardLoadRouteKey, navigationSeedPage)
6063
+ // A STREAMING route's pre-promotion shell always carries a pending hole, so the pop guard
6064
+ // in softNavigate rejects it and every traversal back to this entry refetches. The settled
6065
+ // DOM is this entry's only complete render — re-file it so history restoration stays
6066
+ // network-free. Complete (non-streaming) sources keep their immutable pre-hydration copy.
6067
+ if (streamHasPendingHole(hardLoadRestoreSource.html) && !streamHasPendingHole(settledHtml)) {
6068
+ cacheEntryDocument(navigationSeedPage)
6069
+ }
5284
6070
  // `prefetch = 'allow-runtime'`: the document that just loaded shows RESOLVED sampled
5285
6071
  // content, but its cacheable stage is the runtime-prefetch shell, which the hard load never
5286
6072
  // fetched. Ask the server for that payload so a later navigation back paints the sampled
5287
6073
  // content instead of the Suspense fallbacks.
5288
- if (documentRuntimePrefetch()) {
5289
- void prefetchRoute(location.href, { full: true, currentUrl: true })
6074
+ if (hardLoadRuntimePrefetch) {
6075
+ void prefetchRoute(hardLoadRestoreSource.finalUrl, { full: true, currentUrl: true })
5290
6076
  }
5291
6077
  }
5292
6078
  if (document.readyState === 'complete') captureHardLoad()