@wular/pnext 0.0.24 → 0.1.1

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 (54) 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/reference/navigation.md +2 -2
  6. package/src/api/client-navigation.ts +7 -5
  7. package/src/cli/adapters/vercel-warm.ts +120 -28
  8. package/src/cli/build.ts +56 -15
  9. package/src/cli/create.ts +1 -1
  10. package/src/cli/dev.ts +2 -1
  11. package/src/cli/index.ts +146 -10
  12. package/src/cli/migrate/report.ts +1 -1
  13. package/src/cli/serve/pipeline.ts +23 -6
  14. package/src/cli/serve/ui.ts +1 -1
  15. package/src/cli/start.ts +2 -1
  16. package/src/cli/typegen.ts +5 -2
  17. package/src/client/build.ts +217 -58
  18. package/src/client/chunk-fold.ts +7 -1
  19. package/src/client/entry.ts +44 -7
  20. package/src/client/router/runtime.ts +1099 -236
  21. package/src/client/router/types.ts +15 -0
  22. package/src/compat/actions/client-plugin.ts +10 -0
  23. package/src/compat/actions/rewrite.ts +1 -0
  24. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  25. package/src/compat/client/navigation-scroll.ts +29 -3
  26. package/src/compat/client/optimistic-routing.ts +2 -2
  27. package/src/compat/client/segment-cache-policy.ts +4 -18
  28. package/src/compat/client/segment-cache.ts +104 -0
  29. package/src/compat/client/segment-prefetch.ts +6 -6
  30. package/src/compat/next/config-loader.ts +6 -4
  31. package/src/compat/next/navigation.ts +16 -2
  32. package/src/compat/pages/router.ts +102 -9
  33. package/src/compat/protocol.ts +10 -1
  34. package/src/compat/register/actions.ts +2 -0
  35. package/src/compat/register/bundler.ts +6 -6
  36. package/src/compat/register/routing.ts +3 -4
  37. package/src/compat/tsconfig-defaults.ts +3 -2
  38. package/src/config.ts +3 -3
  39. package/src/css/postcss.ts +450 -17
  40. package/src/dev/client-actions.ts +2 -0
  41. package/src/dev/server.ts +87 -14
  42. package/src/render/island-context.ts +3 -0
  43. package/src/render/ppr.ts +14 -0
  44. package/src/render/renderer.ts +273 -43
  45. package/src/request/context.ts +32 -11
  46. package/src/resolve/scan-facts.ts +23 -4
  47. package/src/routing/proxy.ts +29 -25
  48. package/src/routing/routes.ts +1 -1
  49. package/src/runtime/loader.ts +47 -1
  50. package/src/runtime/modules.ts +546 -65
  51. package/src/runtime/vendor-build.ts +109 -22
  52. package/src/runtime/vendor.ts +7 -3
  53. package/src/utils/ansi.ts +3 -1
  54. package/src/utils/fs.ts +17 -3
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  exportDocumentFetcher,
22
22
  loadingShellPredictionPolicy,
23
+ prefetchStaleTimePolicy,
23
24
  prefetchStaleTimeMs,
24
25
  revalidationPrefetchDelayMs,
25
26
  segmentCachePolicy,
@@ -30,7 +31,9 @@ import {
30
31
  emitLocationChange,
31
32
  emitNavigationCommit,
32
33
  emitNavigationStart,
34
+ navigationScrollAction,
33
35
  scheduleNavigationScroll,
36
+ setNavigationScrollAction,
34
37
  withSilentLocationChange,
35
38
  } from './events'
36
39
  import type {
@@ -39,13 +42,14 @@ import type {
39
42
  EntryModule,
40
43
  LinkPrefetchMode,
41
44
  LoadingShellPrediction,
45
+ NavigationScrollAction,
42
46
  PrefetchedPage,
43
47
  PrefetchOptions,
44
48
  SegmentCacheHit,
45
49
  SoftNavigateOptions,
46
50
  } from './types'
47
51
  import type { LinkClickTarget } from './hub'
48
- import { elementInPageSlot, graftPageSlot, loadingShellTarget } from './page-slot'
52
+ import { elementInPageSlot, graftPageSlot, loadingShellTarget, pageSlotRange } from './page-slot'
49
53
  // ---------------------------------------------------------------------------
50
54
  // DOCUMENTS
51
55
  // ---------------------------------------------------------------------------
@@ -57,14 +61,17 @@ export interface BrowserRouteState {
57
61
  runtimePrefetch?: boolean
58
62
  }
59
63
 
64
+ const ROUTE_STATE_PREFIX = 'window.__PNEXT_ROUTE__='
65
+
60
66
  function documentRouteState(doc: Document): BrowserRouteState | undefined {
61
- const prefix = 'window.__PNEXT_ROUTE__='
62
67
  const source = [...doc.scripts]
63
68
  .map(script => script.textContent ?? '')
64
- .find(text => text.startsWith(prefix))
69
+ .find(text => text.startsWith(ROUTE_STATE_PREFIX))
65
70
  if (!source) return undefined
66
71
  try {
67
- return JSON.parse(source.slice(prefix.length).replace(/;\s*$/, '')) as BrowserRouteState
72
+ return JSON.parse(
73
+ source.slice(ROUTE_STATE_PREFIX.length).replace(/;\s*$/, ''),
74
+ ) as BrowserRouteState
68
75
  } catch {
69
76
  return undefined
70
77
  }
@@ -184,19 +191,6 @@ function currentNavState(): DocumentNavState {
184
191
  return state
185
192
  }
186
193
 
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
194
  export interface StaticHint {
201
195
  isStatic: boolean
202
196
  staleTime?: number
@@ -215,15 +209,6 @@ function documentStaticHintFromHtml(html: string): StaticHint | null {
215
209
  return staticHintFromJson(navStateJson(html))
216
210
  }
217
211
 
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
212
  /** The same flag read out of a document's SOURCE (a navigation response). */
228
213
  function htmlRuntimePrefetch(html: string): boolean {
229
214
  return routeStateFromHtml(html)?.runtimePrefetch === true
@@ -258,14 +243,15 @@ function staticHintFromJson(json: string | null): StaticHint | null {
258
243
  }
259
244
 
260
245
  function rootLayoutId(doc: Document): string | null {
261
- return doc.documentElement?.getAttribute('data-pnext-root-layout') ?? null
246
+ return doc.documentElement?.getAttribute('data-pnext-root-layout')?.replace(/[?#].*/, '') ?? null
262
247
  }
263
248
 
264
249
  function rootLayoutChanged(incoming: Document) {
265
250
  const current = rootLayoutId(document)
266
251
  const next = rootLayoutId(incoming)
267
- if (current === null || next === null) return false
268
- return current !== next
252
+ // Import versions and cache-busting revisions describe the build that produced a document, not
253
+ // a different root layout. The renderer's canonical id never needs either suffix in production.
254
+ return current !== null && next !== null && current !== next
269
255
  }
270
256
 
271
257
  function isPNextDocument(doc: Document) {
@@ -341,6 +327,8 @@ function materializeStreamedSegments(doc: Document) {
341
327
  for (const chunk of doc.querySelectorAll<HTMLElement>(
342
328
  'div[hidden][data-pnext-stream], template[data-pnext-stream]',
343
329
  )) {
330
+ // Its promotion script has nothing left to do, and re-running it would only trip a script CSP.
331
+ if (chunk.nextElementSibling?.tagName === 'SCRIPT') chunk.nextElementSibling.remove()
344
332
  const id = chunk.getAttribute('data-pnext-stream')
345
333
  const suspense = id
346
334
  ? doc.querySelector(`pnext-suspense[data-pnext-suspense="${CSS.escape(id)}"]`)
@@ -469,26 +457,69 @@ function preloadModule(href: string) {
469
457
  document.head.append(link)
470
458
  }
471
459
 
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) {
460
+ // Sheets this runtime appended that have not settled yet, keyed on absolute href. A paint
461
+ // that installs them and a later commit that awaits them are different calls, so the
462
+ // promise has to outlive the one that created the link.
463
+ const stylesheetLoads = new Map<string, [Promise<void>, () => void]>()
464
+
465
+ // Append the document's stylesheets SYNCHRONOUSLY and hand back the loads still in flight.
466
+ // Every path that puts the destination's content on screen goes through here: a paint that
467
+ // commits a navigation without its route's sheets shows unstyled content until the dynamic
468
+ // stage lands. Resolves on error too — a missing stylesheet should degrade styling, not
469
+ // wedge navigation.
470
+ function installStylesheets(doc: Document): Promise<void>[] {
476
471
  const pending: Promise<void>[] = []
477
472
  for (const link of doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')) {
478
473
  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
- )
474
+ if (!href) continue
475
+ const key = absoluteStylesheetHref(href)
476
+ const installed = findStylesheet(href)
477
+ if (installed) {
478
+ // Already in the document — possibly installed by an earlier paint this
479
+ // navigation, whose load the commit must still wait out.
480
+ const inFlight = stylesheetLoads.get(key!)
481
+ if (inFlight) {
482
+ // A memory-cached/restored sheet can become usable without replaying `load`.
483
+ // Its DOM-visible readiness is authoritative; release the earlier paint's waiter.
484
+ // findStylesheet can return an SSR/body-streamed or island-owned same-href link,
485
+ // so only settle the waiter recorded for the link this runtime created.
486
+ if (installed.sheet) inFlight[1]()
487
+ else pending.push(inFlight[0])
488
+ }
489
+ continue
490
+ }
491
+ const sheet = document.createElement('link')
492
+ sheet.rel = 'stylesheet'
493
+ sheet.href = href
494
+ let settle!: () => void
495
+ const load = new Promise<void>(resolve => {
496
+ settle = () => {
497
+ // The sheet can be pruned while its request is still in flight and then
498
+ // reinstalled by a back navigation. Do not let the abandoned request
499
+ // erase the newer request's promise from the dedup map.
500
+ if (stylesheetLoads.get(key!)?.[0] === load) {
501
+ stylesheetLoads.delete(key!)
502
+ }
503
+ resolve()
504
+ }
505
+ })
506
+ sheet.onload = settle
507
+ sheet.onerror = settle
508
+ if (key) {
509
+ stylesheetLoads.set(key, [load, settle])
510
+ }
511
+ document.head.append(sheet)
512
+ pending.push(load)
513
+ }
514
+ return pending
515
+ }
516
+
517
+ function absoluteStylesheetHref(href: string) {
518
+ try {
519
+ return new URL(href, location.href).href
520
+ } catch {
521
+ return
490
522
  }
491
- return Promise.all(pending)
492
523
  }
493
524
 
494
525
  // Route stylesheets the new document does not use accumulate across navigations;
@@ -522,7 +553,7 @@ function pnextStylesheet(href: string) {
522
553
 
523
554
  function findStylesheet(href: string) {
524
555
  const target = new URL(href, location.href).href
525
- return [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')].some(
556
+ return [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')].find(
526
557
  link => link.href === target,
527
558
  )
528
559
  }
@@ -555,7 +586,9 @@ function materializeClientIslandMarkers(root: Document) {
555
586
  const hasClientRuntime = Boolean(entryScriptSrc(root))
556
587
  while (walker.nextNode()) comments.push(walker.currentNode as Comment)
557
588
  for (const start of comments.reverse()) {
558
- const match = /^(pnext-client(?:-after)?|pnext-page):([^>]*)$/.exec(start.data)
589
+ const match = /^(pnext-client(?:-after)?|pnext-static-children|pnext-page):([^>]*)$/.exec(
590
+ start.data,
591
+ )
559
592
  const kind = match?.[1]
560
593
  const encoded = match?.[2]
561
594
  if (!kind || !encoded || !start.parentNode) continue
@@ -569,7 +602,8 @@ function materializeClientIslandMarkers(root: Document) {
569
602
  continue
570
603
  }
571
604
  const container = document.createElement('div')
572
- const tag = kind === 'pnext-page' ? 'div' : 'pnext-client'
605
+ const tag =
606
+ kind === 'pnext-page' ? 'div' : kind === 'pnext-client-after' ? 'pnext-client' : kind
573
607
  container.innerHTML = `<${tag} ${encoded}></${tag}>`
574
608
  const island = container.firstElementChild
575
609
  if (!island) continue
@@ -908,6 +942,95 @@ function matchPreservedClientPage(
908
942
  return { root, nodes }
909
943
  }
910
944
 
945
+ // When a client layout adopts the page slot, its marker range disappears from the LIVE DOM and the
946
+ // page's client components become ordinary children inside a preserved provider island. Give those
947
+ // components a route-entry key on the incoming adoption source: Preact then unmounts only the page
948
+ // subtree while PageTransition, providers, and layout ancestors stay mounted.
949
+ // `key` is consumed by createElement and is not exposed as an application prop.
950
+ function keySlotlessClientPage(doc: Document, key: string) {
951
+ const slot = pageSlotRange(doc.body)
952
+ if (!slot) return
953
+ const roots: Element[] = []
954
+ for (const node of slot[2]) {
955
+ if (!(node instanceof Element)) continue
956
+ if (node.matches('pnext-client[data-pnext-client]')) roots.push(node)
957
+ roots.push(...node.querySelectorAll('pnext-client[data-pnext-client]'))
958
+ }
959
+ for (const [index, root] of roots.entries()) {
960
+ try {
961
+ const props = JSON.parse(root.getAttribute('data-pnext-props') ?? '{}') as Record<
962
+ string,
963
+ unknown
964
+ >
965
+ props.key = `pnext-page:${key}:${index}`
966
+ root.setAttribute('data-pnext-props', JSON.stringify(props))
967
+ } catch {
968
+ // Invalid island props will be diagnosed by the entry mount; navigation must still commit.
969
+ }
970
+ }
971
+ }
972
+
973
+ function hasSlotlessClientRootLayout(doc: Document, preserved: LiveIslandRoot[]) {
974
+ if (pageSlotRange(document.body) !== null || preserved.length === 0) return false
975
+ return preserved.some((root, index) => {
976
+ // After adoption, a client root layout is itself a direct body child. Its incoming clone may
977
+ // expose the page marker through serialized children, but older/generated entries can keep the
978
+ // route-screen roots beside it instead, so the direct-body topology is the durable signal.
979
+ if (root.parentElement === document.body) return true
980
+ const placeholder = doc.querySelector<LiveIslandRoot>(`[${PRESERVE_ATTRIBUTE}="${index}"]`)
981
+ return placeholder !== null && pageSlotRange(placeholder) !== null
982
+ })
983
+ }
984
+
985
+ async function flushClientEffects() {
986
+ if (window.requestAnimationFrame) {
987
+ await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
988
+ } else {
989
+ await Promise.resolve()
990
+ }
991
+ }
992
+
993
+ // A slotless live tree cannot be partially unmounted through a DOM container: the page lives as
994
+ // adopted children of a preserved provider root. Re-render that root once with the incoming page
995
+ // marker emptied while it is still in the old body. The temporary departure URL keeps pathname-keyed
996
+ // layout effects stable; after Preact flushes the outgoing page's cleanup, the real target render can
997
+ // mount its page fresh against the already-fired popstate flag.
998
+ async function unmountSlotlessClientPage(
999
+ doc: Document,
1000
+ preserved: LiveIslandRoot[],
1001
+ entry: EntryModule | null | undefined,
1002
+ departingRouteKey: string,
1003
+ ): Promise<boolean> {
1004
+ if (!entry?.mountRoute || preserved.length === 0) return false
1005
+ let staged = false
1006
+ for (let index = 0; index < preserved.length; index++) {
1007
+ const placeholder = doc.querySelector<LiveIslandRoot>(`[${PRESERVE_ATTRIBUTE}="${index}"]`)
1008
+ if (!placeholder) continue
1009
+ const source = placeholder.cloneNode(true) as LiveIslandRoot
1010
+ const slot = pageSlotRange(source)
1011
+ if (!slot) continue
1012
+ for (const node of slot[2]) node.remove()
1013
+ if (slot[0] instanceof Element) slot[0].replaceChildren()
1014
+ else slot[0].remove()
1015
+ slot[1]?.remove()
1016
+ preserved[index]!.__pnextIncoming = source
1017
+ staged = true
1018
+ }
1019
+ if (!staged) return false
1020
+
1021
+ const targetState: unknown = history.state
1022
+ const targetHref = location.href
1023
+ const departureHref = new URL(departingRouteKey, location.origin).href
1024
+ withSilentLocationChange(() => history.replaceState(targetState, '', departureHref))
1025
+ try {
1026
+ await entry.mountRoute()
1027
+ await flushClientEffects()
1028
+ } finally {
1029
+ withSilentLocationChange(() => history.replaceState(targetState, '', targetHref))
1030
+ }
1031
+ return true
1032
+ }
1033
+
911
1034
  // A query-only nav that stays on the SAME route renders the same whole-page `'use client'`
912
1035
  // root, so its live DOM must be preserved exactly like a refresh - otherwise the page
913
1036
  // REMOUNTS and a queued action's `setState` closure updates an orphaned component. The
@@ -925,6 +1048,7 @@ function swapBody(
925
1048
  preservedPage: PreservedClientPage | null = null,
926
1049
  reusable = reusableBodyChildren(),
927
1050
  ) {
1051
+ const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
928
1052
  const fragment = document.createDocumentFragment()
929
1053
  // The incoming document's entry src, remembered on <html> below so a snapshot
930
1054
  // of the swapped (script-less) live DOM can still name its entry.
@@ -938,6 +1062,11 @@ function swapBody(
938
1062
  continue
939
1063
  }
940
1064
  if (node.hasAttribute('data-pnext-dev')) continue
1065
+ // Callers install the route state from the document; re-running it only trips a script CSP.
1066
+ if (node.text.startsWith(ROUTE_STATE_PREFIX)) {
1067
+ fragment.append(document.importNode(node, true))
1068
+ continue
1069
+ }
941
1070
  // Parser-created scripts are inert; rebuild them so props, route
942
1071
  // state, and streamed-chunk patches execute in document order when the
943
1072
  // fragment lands in the live document.
@@ -950,7 +1079,10 @@ function swapBody(
950
1079
  const live = takeReusableBodyChild(reusable, node)
951
1080
  fragment.append(live ?? document.importNode(node, true))
952
1081
  }
953
- if (preserved.length > 0) graftPreservedIslands(fragment, preserved)
1082
+ const connected =
1083
+ preserved.length > 0
1084
+ ? graftPreservedIslands(fragment, preserved)
1085
+ : new Map<Node, LiveIslandRoot>()
954
1086
  if (segments.length > 0) graftPreservedServerSegments(fragment, segments)
955
1087
  if (preservedPage) {
956
1088
  const placeholder = fragment.querySelector(`[${PAGE_PRESERVE_ATTRIBUTE}]`)
@@ -958,19 +1090,149 @@ function swapBody(
958
1090
  // preact tree (and its state) carries straight into the new body.
959
1091
  if (placeholder) placeholder.replaceWith(...preservedPage.nodes)
960
1092
  }
961
- document.body.replaceChildren(fragment)
1093
+ if (connected.size === 0) {
1094
+ document.body.replaceChildren(fragment)
1095
+ } else {
1096
+ // Reconcile around direct-body layout roots without ever disconnecting them.
1097
+ let cursor = document.body.firstChild
1098
+ for (const desired of [...fragment.childNodes]) {
1099
+ const node = connected.get(desired) ?? desired
1100
+ if (node === cursor) cursor = cursor.nextSibling
1101
+ else document.body.insertBefore(node, cursor)
1102
+ }
1103
+ while (cursor) {
1104
+ const next = cursor.nextSibling
1105
+ cursor.remove()
1106
+ cursor = next
1107
+ }
1108
+ }
1109
+ if (paintHold) placeNavigationPaintHold(paintHold)
962
1110
  // Track the entry of what is now on screen (dropped when the incoming route
963
1111
  // has none, so a stale entry is never attributed to it).
964
1112
  if (entrySrc) document.documentElement.setAttribute(ENTRY_SCRIPT_ATTRIBUTE, entrySrc)
965
1113
  else document.documentElement.removeAttribute(ENTRY_SCRIPT_ATTRIBUTE)
966
1114
  }
967
1115
 
1116
+ /** Keep the last complete screen painted while client roots mount into the committed document. */
1117
+ type NavigationPaintHold = HTMLElement & {
1118
+ __pnextObserver?: MutationObserver
1119
+ /** The window scroll when the copy was taken. */
1120
+ __pnextScroll?: [number, number]
1121
+ }
1122
+
1123
+ function removeNavigationPaintHold(hold: NavigationPaintHold) {
1124
+ hold.__pnextObserver?.disconnect()
1125
+ hold.remove()
1126
+ }
1127
+
1128
+ function createNavigationPaintHold(): NavigationPaintHold | null {
1129
+ // A newer navigation may start before the prior hold's settling frame. Retire
1130
+ // that transaction synchronously so holds never nest (the older async release
1131
+ // becomes a no-op).
1132
+ const priorHold = document.querySelector<NavigationPaintHold>(
1133
+ '[data-pnext-navigation-paint-hold]',
1134
+ )
1135
+ if (priorHold) removeNavigationPaintHold(priorHold)
1136
+ const roots = [...document.body.children].filter(
1137
+ element =>
1138
+ !element.hasAttribute('data-pnext-navigation-paint-hold') &&
1139
+ !/^(SCRIPT|STYLE|LINK|TEMPLATE)$/.test(element.tagName) &&
1140
+ element.textContent?.trim(),
1141
+ )
1142
+ if (roots.length === 0) return null
1143
+ const hold: NavigationPaintHold = document.createElement('div')
1144
+ hold.setAttribute('data-pnext-navigation-paint-hold', '')
1145
+ hold.setAttribute('aria-hidden', 'true')
1146
+ hold.style.cssText =
1147
+ 'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
1148
+ hold.__pnextScroll = [window.scrollX, window.scrollY]
1149
+ // A <body> shell over the whole hold: body styles lay out and paint the copy as the page was.
1150
+ const body = document.body.cloneNode() as HTMLElement
1151
+ body.style.minHeight = '100%'
1152
+ body.append(...roots.map(root => root.cloneNode(true)))
1153
+ hold.append(body)
1154
+ // The copy is paint-only: route mount scans and id lookups must never treat it as live UI.
1155
+ for (const node of hold.querySelectorAll('[data-pnext-client]'))
1156
+ node.removeAttribute('data-pnext-client')
1157
+ for (const node of hold.querySelectorAll('[id]')) node.removeAttribute('id')
1158
+ // Cloned media are NEW elements: an `autoplay` attribute replays them on insertion, and
1159
+ // cloneNode copies attributes but not the live `muted` property - a video muted only via
1160
+ // property starts AUDIBLE in the copy. Freeze every clone on its current frame instead.
1161
+ for (const media of hold.querySelectorAll<HTMLMediaElement>('video,audio')) {
1162
+ media.removeAttribute('autoplay')
1163
+ media.muted = true
1164
+ media.setAttribute('muted', '')
1165
+ media.preload = 'none'
1166
+ media.removeAttribute('src')
1167
+ for (const source of media.querySelectorAll('source')) source.remove()
1168
+ }
1169
+ // A cloned iframe re-loads (and can re-play) its document; the paint copy needs only the box.
1170
+ for (const frame of hold.querySelectorAll('iframe')) frame.removeAttribute('src')
1171
+ return hold
1172
+ }
1173
+
1174
+ function attachNavigationPaintHold(
1175
+ hold: NavigationPaintHold | null,
1176
+ scrollTop: number,
1177
+ sequence: number,
1178
+ ) {
1179
+ if (!hold || sequence !== navigationSequence) return
1180
+ // The opaque fixed clone covers the viewport while the committed tree mounts.
1181
+ // Keep that live tree visible to focus management and selector observers;
1182
+ // hiding <body> also hides every real destination element from browser gates.
1183
+ placeNavigationPaintHold(hold)
1184
+ hold.__pnextObserver ??= new MutationObserver(() => {
1185
+ if (sequence !== navigationSequence) {
1186
+ return removeNavigationPaintHold(hold)
1187
+ }
1188
+ if (!hold.isConnected) placeNavigationPaintHold(hold)
1189
+ })
1190
+ hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
1191
+ const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
1192
+ if (scrollRoot) scrollRoot.scrollTop = scrollTop
1193
+ }
1194
+
1195
+ /** Append the hold scrolled as the departing page was; appending resets an element's scroll. */
1196
+ function placeNavigationPaintHold(hold: NavigationPaintHold) {
1197
+ document.body.append(hold)
1198
+ if (hold.__pnextScroll) hold.scrollTo?.(...hold.__pnextScroll)
1199
+ }
1200
+
1201
+ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null, reveal?: () => void) {
1202
+ if (!hold) return
1203
+ // mountRoute, client effects and the navigation commit have completed before
1204
+ // this runs. Keep the departing frame through the next rendering opportunity,
1205
+ // then reveal the committed tree; content length is not a readiness signal.
1206
+ if (window.requestAnimationFrame) {
1207
+ await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
1208
+ }
1209
+ reveal?.()
1210
+ removeNavigationPaintHold(hold)
1211
+ }
1212
+
968
1213
  /**
969
1214
  * Give focus back to the element that had it before the body swap: reattaching a
970
1215
  * node blurs it, so a retained layout element would silently drop focus to <body>.
971
1216
  * Focus the new tree already claimed (autofocus, segment focus) stands.
972
1217
  */
973
- function restoreSwapFocus(focused: Element | null): void {
1218
+ function restoreSwapFocus(
1219
+ focused: Element | null,
1220
+ navigationTarget: HTMLElement | null = null,
1221
+ ): void {
1222
+ // Next applies scroll/focus after the changed segment has committed. pnext
1223
+ // resolves that segment before a client root can dissolve its page markers,
1224
+ // so remember the element the scroll action focused and reaffirm it after
1225
+ // client reconciliation. This is intentionally ahead of restoring retained
1226
+ // layout focus: an interactive/scrollable destination segment wins over the
1227
+ // link that initiated the navigation.
1228
+ if (navigationTarget?.isConnected) {
1229
+ try {
1230
+ navigationTarget.focus({ preventScroll: true })
1231
+ } catch {
1232
+ // Focus is best-effort; continue with retained-layout focus below.
1233
+ }
1234
+ if (document.activeElement === navigationTarget) return
1235
+ }
974
1236
  if (!(focused instanceof HTMLElement) || focused === document.body) return
975
1237
  if (!focused.isConnected) return
976
1238
  if (document.activeElement !== null && document.activeElement !== document.body) return
@@ -1249,6 +1511,7 @@ function disposeCachedRoot(root: LiveIslandRoot) {
1249
1511
  // fresh island props move onto the live element, and the detached placeholder (fresh
1250
1512
  // SSR children) is stashed for the entry's mountRoute to re-render in place.
1251
1513
  function graftPreservedIslands(fragment: DocumentFragment, preserved: LiveIslandRoot[]) {
1514
+ const connected = new Map<Node, LiveIslandRoot>()
1252
1515
  for (const placeholder of [...fragment.querySelectorAll(`[${PRESERVE_ATTRIBUTE}]`)]) {
1253
1516
  const live = preserved[Number(placeholder.getAttribute(PRESERVE_ATTRIBUTE))]
1254
1517
  placeholder.removeAttribute(PRESERVE_ATTRIBUTE)
@@ -1260,14 +1523,26 @@ function graftPreservedIslands(fragment: DocumentFragment, preserved: LiveIsland
1260
1523
  live.setAttribute(attribute.name, attribute.value)
1261
1524
  }
1262
1525
  live.__pnextIncoming = placeholder
1263
- placeholder.replaceWith(live)
1526
+ if (live.parentNode === document.body && placeholder.parentNode === fragment) {
1527
+ const marker = document.createComment('pnext-preserved-root')
1528
+ placeholder.replaceWith(marker)
1529
+ connected.set(marker, live)
1530
+ } else {
1531
+ placeholder.replaceWith(live)
1532
+ }
1264
1533
  }
1534
+ return connected
1265
1535
  }
1266
1536
 
1267
1537
  function isDevDocument() {
1268
1538
  return Boolean(document.querySelector('script[data-pnext-dev]'))
1269
1539
  }
1270
1540
 
1541
+ // Next's dev router neither prefetches nor paints loading states; core dev navigates like production.
1542
+ function nextDevDocument() {
1543
+ return prefetchStaleTimePolicy !== undefined && isDevDocument()
1544
+ }
1545
+
1271
1546
  // `replace` is also set for history TRAVERSALS (options.pop): assign() would
1272
1547
  // push a new entry over the popped one and truncate the forward stack, so a
1273
1548
  // bailout during back/forward must replace the current entry instead.
@@ -1278,17 +1553,19 @@ function hardNavigate(href: string, replace?: boolean) {
1278
1553
 
1279
1554
  function saveScrollPosition() {
1280
1555
  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 })
1556
+ // The bfcache document is recorded when the route commits (and when a hard load settles).
1557
+ // Do not overwrite it from entryDocCache here: a streamed hard load intentionally keeps its
1558
+ // immutable pre-runtime source for history restoration, which can be only the loading shell.
1559
+ // Refiling that source on departure would downgrade the complete settled bfcache document and
1560
+ // make a full-prefetch Link back to the route commit a permanently suspended shell.
1561
+ // The scrollable height rides along so a pop restore into a still-short swapped body can
1562
+ // reserve it — the browser clamps scrollTo against the live height, silently losing the
1563
+ // position when the restored content mounts a beat after the swap.
1290
1564
  history.replaceState(
1291
- { ...historyState(), __pnextScroll: [window.scrollX, window.scrollY] },
1565
+ {
1566
+ ...historyState(),
1567
+ __pnextScroll: [window.scrollX, window.scrollY, document.documentElement.scrollHeight],
1568
+ },
1292
1569
  '',
1293
1570
  location.href,
1294
1571
  )
@@ -1297,6 +1574,24 @@ function saveScrollPosition() {
1297
1574
  }
1298
1575
  }
1299
1576
 
1577
+ // Defined by compat.next builds, whose router policies replace core's scroll and refresh ones.
1578
+ declare const __PNEXT_NEXT_ROUTER__: boolean | undefined
1579
+
1580
+ // Core's scroll policy when compat installs none: a new page starts at the top, a traversal
1581
+ // returns to where its entry was left.
1582
+ const coreNavigationScroll: NavigationScrollAction = (url, { pop, scroll }) => {
1583
+ const state = historyState()
1584
+ const [x = 0, y = 0] = pop
1585
+ ? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined) ?? [])
1586
+ : []
1587
+ const target = !pop && url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
1588
+ if (target) target.scrollIntoView()
1589
+ else if (pop || scroll !== false) window.scrollTo(x, y)
1590
+ }
1591
+
1592
+ // Where each entry was left, pops included, which cannot write the state of the entry they leave.
1593
+ const entryScroll = new Map<unknown, number[]>()
1594
+
1300
1595
  function storeNavState() {
1301
1596
  try {
1302
1597
  history.replaceState(
@@ -2005,6 +2300,14 @@ export function evictClientRouterCache(options: { rearmVisiblePrefetches?: boole
2005
2300
  entryDocCache.clear()
2006
2301
  bfDocCache.clear()
2007
2302
  urlStaticFreshUntil.clear()
2303
+ // Element sets outlive an evicted cache otherwise: in a multi-document host (tests, jsdom
2304
+ // embedders) the previous document's links stay registered and get re-pinged against the next
2305
+ // document's tree, spending prefetches nothing asked for.
2306
+ visiblePrefetchElements.clear()
2307
+ // Pending loads belong to the document that created their <link> elements. In a
2308
+ // multi-document host (tests, jsdom embedders) a never-settling promise keyed by an
2309
+ // absolute href would otherwise stall the next document's first commit on the same URL.
2310
+ stylesheetLoads.clear()
2008
2311
  for (const entry of invalidated) notifyPrefetchInvalidation(entry, true)
2009
2312
  if (options.rearmVisiblePrefetches !== false) rearmVisiblePrefetches()
2010
2313
  }
@@ -2172,6 +2475,17 @@ function segmentSchedulerEnabled(): boolean {
2172
2475
  )
2173
2476
  }
2174
2477
 
2478
+ // Next always restores a history entry's bfcacheId, but retaining the inactive
2479
+ // React tree in an <Activity> boundary is exclusive to cacheComponents. Compat
2480
+ // stamps this exact value into the document; an unstamped core app must keep its
2481
+ // ordinary unmount/remount lifecycle.
2482
+ function activityBfcacheEnabled(): boolean {
2483
+ return (
2484
+ (process.browser || typeof window !== 'undefined') &&
2485
+ (window as { __PNEXT_SEGMENT_SCHEDULER__?: boolean }).__PNEXT_SEGMENT_SCHEDULER__ === true
2486
+ )
2487
+ }
2488
+
2175
2489
  function acquirePrefetchSlot(task: PrefetchFetchTask, phase: number): Promise<boolean> {
2176
2490
  task.phase = phase
2177
2491
  if (task.cancelled) return Promise.resolve(false)
@@ -2276,6 +2590,11 @@ export interface PrefetchEntry {
2276
2590
 
2277
2591
  const prefetchCache = new Map<string, PrefetchEntry>()
2278
2592
  const prefetchedElements = new Set<Element>()
2593
+ // Next re-evaluates every currently visible Link when the committed URL/base tree changes:
2594
+ // the same href may need a different segment delta from the new route. Keep this separate from
2595
+ // `prefetchedElements` (which also includes intent-only and formerly visible elements used by
2596
+ // revalidation) so an ordinary navigation only pings links that are actually on screen.
2597
+ const visiblePrefetchElements = new Set<Element>()
2279
2598
 
2280
2599
  function touchCacheEntry<T>(cache: Map<string, T>, key: string, entry: T): void {
2281
2600
  // Map insertion order is our LRU order. Reads must move an entry to the end
@@ -2587,9 +2906,7 @@ export function prefetchRoute(
2587
2906
  href: string,
2588
2907
  options: PrefetchOptions = {},
2589
2908
  ): Promise<PrefetchedPage | null> {
2590
- // Dev pages render per request and entries build on demand; hover prefetch
2591
- // would hammer the dev server for little gain. Navigation still fetches.
2592
- if (isDevDocument()) return Promise.resolve(null)
2909
+ if (nextDevDocument()) return Promise.resolve(null)
2593
2910
  if (isBotUserAgent()) return Promise.resolve(null)
2594
2911
  // `strict` is handled at the facade, the only entry point that can be handed
2595
2912
  // an unparseable href; everything reaching here already resolved once.
@@ -2598,12 +2915,13 @@ export function prefetchRoute(
2598
2915
  const key = url.pathname + url.search
2599
2916
  const full = Boolean(options.full)
2600
2917
  const cacheKey = prefetchCacheKey(key, full)
2918
+ // Track the element before the current-route short circuit. A visible link to the page we are
2919
+ // already on has an empty prefetch delta now, but its delta changes after navigating away and
2920
+ // it must be reconsidered against that new base tree.
2921
+ if (options.element) prefetchedElements.add(options.element)
2601
2922
  // Prefetching the page we are already on is a no-op (Next's router produces
2602
2923
  // 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
- ) {
2924
+ if ((key === locationKey() || key === routerState.activeRouteKey) && !options.currentUrl) {
2607
2925
  return Promise.resolve(null)
2608
2926
  }
2609
2927
  // A default prefetch carries only static segment data; search params are dynamic data
@@ -2612,7 +2930,6 @@ export function prefetchRoute(
2612
2930
  if (!options.full && url.pathname === location.pathname && !options.currentUrl) {
2613
2931
  return Promise.resolve(null)
2614
2932
  }
2615
- if (options.element) prefetchedElements.add(options.element)
2616
2933
  if (revalidationPrefetchBlocked) return Promise.resolve(null)
2617
2934
  // Hover intent on a link whose prefetch is already scheduled/pending: boost
2618
2935
  // it to the reserved Intent lane instead of spawning anything new.
@@ -2888,13 +3205,13 @@ function rscHash(input: string): string {
2888
3205
  return (hash >>> 0).toString(36)
2889
3206
  }
2890
3207
 
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>`.
3208
+ // Read a streamed HTML response, offering the shell at the response-chunk boundary where the
3209
+ // navigation can commit it. A pending Suspense hole paints its fallback at that commit, exactly
3210
+ // like a browser receiving a streamed document shell; if the shell and its replacement arrive in
3211
+ // the same read, the complete content wins without an intermediate paint. The shell carries every
3212
+ // Suspense fallback as a closed `<pnext-suspense>`; replacement IDs distinguish resolved holes.
3213
+ // A route with no Suspense boundary never fires onShell. The in-place suspending path closes its
3214
+ // fallback with preact's `<!--/$s:ID-->` comment and streams content as `<preact-island data-target>`.
2898
3215
  const STREAM_CHUNK_MARKER = '<div hidden data-pnext-stream'
2899
3216
  const INLINE_CHUNK_MARKER = '<div hidden><preact-island'
2900
3217
  const SUSPENSE_FALLBACK_CLOSE = '</pnext-suspense>'
@@ -2903,13 +3220,43 @@ const SUSPENSE_FALLBACK_CLOSE = '</pnext-suspense>'
2903
3220
  const INLINE_FALLBACK_CLOSE = '</pnext-hole>'
2904
3221
 
2905
3222
  function streamChunkCut(buffer: string) {
2906
- const cuts = [buffer.indexOf(STREAM_CHUNK_MARKER), buffer.indexOf(INLINE_CHUNK_MARKER)].filter(
2907
- index => index !== -1,
3223
+ const streamed = buffer.indexOf(STREAM_CHUNK_MARKER)
3224
+ const inline = buffer.indexOf(INLINE_CHUNK_MARKER)
3225
+ return streamed < 0 ? inline : inline < 0 ? streamed : Math.min(streamed, inline)
3226
+ }
3227
+
3228
+ // A network read can end halfway through its final continuation. DOMParser auto-closes that
3229
+ // carrier, which would make materializeStreamedSegments graft partial markup over the fallback.
3230
+ // Stream carriers are top-level divs, so balance their nested div tags and retain every complete
3231
+ // same-read carrier while hiding the incomplete trailing one from shell logic.
3232
+ function completeStreamedPrefix(buffer: string): string {
3233
+ const start = Math.max(
3234
+ buffer.lastIndexOf(STREAM_CHUNK_MARKER),
3235
+ buffer.lastIndexOf(INLINE_CHUNK_MARKER),
2908
3236
  )
2909
- return cuts.length > 0 ? Math.min(...cuts) : -1
3237
+ if (start < 0) return buffer
3238
+ const tail = buffer.slice(start)
3239
+ return (tail.match(/<div\b/g)?.length ?? 0) === (tail.match(/<\/div>/g)?.length ?? 0)
3240
+ ? buffer
3241
+ : buffer.slice(0, start)
2910
3242
  }
2911
3243
 
2912
- async function readStreamedBody(
3244
+ function streamHasPendingHole(buffer: string) {
3245
+ for (const match of buffer.matchAll(/data-pnext-(?:suspense|hole)="([^"]+)"/g)) {
3246
+ const suffix = `="${match[1]}"`
3247
+ if (
3248
+ !buffer.includes(`data-pnext-stream${suffix}`) &&
3249
+ // Scope data-target to pnext's exact inline continuation wire form; app markup commonly
3250
+ // uses the bare attribute for toggles and tabs.
3251
+ !buffer.includes(`<preact-island hidden data-target${suffix}`)
3252
+ ) {
3253
+ return true
3254
+ }
3255
+ }
3256
+ return false
3257
+ }
3258
+
3259
+ export async function readStreamedBody(
2913
3260
  response: Response,
2914
3261
  onShell: (shellHtml: string) => void,
2915
3262
  ): Promise<string> {
@@ -2921,20 +3268,23 @@ async function readStreamedBody(
2921
3268
  for (;;) {
2922
3269
  const { done, value } = await reader.read()
2923
3270
  if (value) buffer += decoder.decode(value, { stream: true })
3271
+ const shellBuffer = completeStreamedPrefix(buffer)
2924
3272
  if (
2925
3273
  !shellDelivered &&
2926
- (buffer.includes(SUSPENSE_FALLBACK_CLOSE) || buffer.includes(INLINE_FALLBACK_CLOSE)) &&
3274
+ (shellBuffer.includes(SUSPENSE_FALLBACK_CLOSE) ||
3275
+ shellBuffer.includes(INLINE_FALLBACK_CLOSE)) &&
2927
3276
  // A read that already ends the document has nothing left to stream: painting the
2928
3277
  // pre-chunk loading shell would commit a fallback stage the immediate full-document
2929
3278
  // swap replaces, and that replacement rides island mounts. Skip the shell and let
2930
3279
  // the caller swap the complete, materialized document.
2931
- !/<\/html>\s*$/.test(buffer)
3280
+ !/<\/html>\s*$/.test(shellBuffer)
2932
3281
  ) {
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>`)
3282
+ if (streamHasPendingHole(shellBuffer)) {
3283
+ shellDelivered = true
3284
+ // Expose only holes still pending at this commit. Continuations already present in the
3285
+ // read are included so showLoadingShell can materialize them before choosing a fallback.
3286
+ onShell(`${shellBuffer}</body></html>`)
3287
+ }
2938
3288
  }
2939
3289
  // A late metadata tail (LATE_METADATA_HEADER) rides after the document bytes, so the
2940
3290
  // navigation must not wait for it: everything before the marker IS the document and
@@ -3011,6 +3361,8 @@ async function fetchPage(
3011
3361
  href: string,
3012
3362
  options: {
3013
3363
  navState?: DocumentNavState
3364
+ /** Rendered URL this navigation departed from (popstate location already names the target). */
3365
+ fromUrl?: string
3014
3366
  /**
3015
3367
  * 'auto' - a default prefetch (sends `next-router-prefetch`). 'full' - a
3016
3368
  * `prefetch={true}` full-page prefetch, which Next issues WITHOUT that header.
@@ -3048,7 +3400,7 @@ async function fetchPage(
3048
3400
  'next-router-state-tree': encodeURIComponent(JSON.stringify(state)),
3049
3401
  // The current URL identifies the already-rendered branch. A prefetch uses
3050
3402
  // it to retain shared layouts and stop at the target loading boundary.
3051
- 'next-url': location.pathname + location.search,
3403
+ 'next-url': options.fromUrl ?? locationKey(),
3052
3404
  }
3053
3405
  if (options.prefetch === 'auto') headers['next-router-prefetch'] = '1'
3054
3406
  // Only the streamed-navigation read path (readStreamedBody) can consume a
@@ -3413,7 +3765,7 @@ async function fetchPage(
3413
3765
  */
3414
3766
  async function fetchPageFrameNavigation(
3415
3767
  url: URL,
3416
- options: { navState?: DocumentNavState; sameUrl: boolean },
3768
+ options: { navState?: DocumentNavState; sameUrl: boolean; fromUrl?: string },
3417
3769
  ): Promise<PrefetchedPage | null> {
3418
3770
  const policy = segmentCachePolicy
3419
3771
  // `output: 'export'` has no server to negotiate a frame with.
@@ -3431,7 +3783,7 @@ async function fetchPageFrameNavigation(
3431
3783
  'x-pnext-soft-nav': '1',
3432
3784
  'x-pnext-nav-state': encodeURIComponent(JSON.stringify(state)),
3433
3785
  'next-router-state-tree': encodeURIComponent(JSON.stringify(state)),
3434
- 'next-url': location.pathname + location.search,
3786
+ 'next-url': options.fromUrl ?? locationKey(),
3435
3787
  [SEGMENT_PREFETCH_HEADER]: PAGE_SEGMENT_REQUEST_PATH,
3436
3788
  }
3437
3789
  const variant = `nav:${PAGE_SEGMENT_REQUEST_PATH}`
@@ -3531,6 +3883,10 @@ function recordSegmentEntry(input: {
3531
3883
  // and satisfies a sibling's prefetch without ever committing network-free.
3532
3884
  static: input.segmentPrerendered || input.shellPrerendered,
3533
3885
  runtime,
3886
+ // The server TRUNCATED this response at its postponed boundary: the payload is this
3887
+ // URL's own prerendered static stage, so a navigation may paint it however little of
3888
+ // the page sits outside the holes. A runtime sample is not that - it is request data.
3889
+ postponedShell: input.shellOnly && !runtime,
3534
3890
  })
3535
3891
  }
3536
3892
 
@@ -3660,13 +4016,20 @@ function mergeOutlinedHead(html: string, headFragment: string): string {
3660
4016
  // (anchorInlineSuspenseHoles). Return the first hole's parent plus the fallback nodes it
3661
4017
  // wraps, so the shell paint has the same anchor and source shape as the
3662
4018
  // `<pnext-suspense>` path.
3663
- function inlineSuspenseRange(doc: Document): { anchor: Element; nodes: Node[] } | null {
3664
- const hole = doc.querySelector('pnext-hole[data-pnext-hole]')
4019
+ function inlineSuspenseRange(
4020
+ doc: Document,
4021
+ ): { anchor: Element; nodes: Node[]; hole: Element } | null {
4022
+ // A loading.js hole carries the depth attribute the renderer lifted onto it; prefer it over
4023
+ // an app `<Suspense>` hole exactly as the marker wire form prefers its stamped marker.
4024
+ const hole =
4025
+ doc.querySelector(`pnext-hole[data-pnext-hole][${LOADING_DEPTH_ATTRIBUTE}]`) ??
4026
+ doc.querySelector('pnext-hole[data-pnext-hole]')
3665
4027
  if (!hole?.parentElement) return null
3666
- return { anchor: hole.parentElement, nodes: [...hole.childNodes] }
4028
+ return { anchor: hole.parentElement, nodes: [...hole.childNodes], hole }
3667
4029
  }
3668
4030
 
3669
- function showLoadingShell(
4031
+ /** Exported for the DOM-level unit test (see `paintStaticStageSubtree`). */
4032
+ export function showLoadingShell(
3670
4033
  shellHtml: string,
3671
4034
  sequence: number,
3672
4035
  target: URL,
@@ -3678,23 +4041,47 @@ function showLoadingShell(
3678
4041
  * STAGE so its fallbacks are unwrapped - committed content carries no stream wrappers.
3679
4042
  */
3680
4043
  allowWithoutBoundary = false,
4044
+ /** The shell came from this navigation's live response at its commit boundary. */
4045
+ allowResponseSuspense = false,
3681
4046
  ) {
3682
4047
  if (sequence !== navigationSequence) return false
3683
4048
  if (typeof DOMParser === 'undefined') return false
3684
4049
  const doc = new DOMParser().parseFromString(shellHtml, 'text/html')
3685
4050
  materializeClientIslandMarkers(doc)
3686
- const marker = doc.querySelector('pnext-suspense[data-pnext-suspense]')
4051
+ // A STATIC STAGE owns whatever chunks streamed with it, so resolve them before picking what
4052
+ // to paint - `paintStaticStageSubtree` already does, and painting the fallback of a boundary
4053
+ // whose content is right there would put a placeholder on screen for content we hold.
4054
+ if (allowWithoutBoundary || allowResponseSuspense) materializeStreamedSegments(doc)
4055
+ // A shell can carry markers for BOTH a loading.js boundary and the app's own
4056
+ // `<Suspense>`; prefer the loading.js one. Cached shells require that explicit route boundary,
4057
+ // while a live response paints any hole still pending when its shell commits.
4058
+ const marker =
4059
+ doc.querySelector(`pnext-suspense[data-pnext-suspense][${LOADING_DEPTH_ATTRIBUTE}]`) ??
4060
+ doc.querySelector('pnext-suspense[data-pnext-suspense]')
3687
4061
  const inline = marker ? null : inlineSuspenseRange(doc)
3688
4062
  const suspense = marker ?? inline?.anchor
3689
4063
  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
4064
+ // Either wire form can carry the loading.js stamp; the hole is the inline form's marker.
4065
+ const boundary = marker ?? inline?.hole ?? null
4066
+ const loadingBoundary = isLoadingBoundaryMarker(boundary)
4067
+ if (!allowWithoutBoundary && !loadingBoundary && !allowResponseSuspense) {
4068
+ return false
4069
+ }
4070
+ if (!loadingBoundaryChanges(boundary, target)) return false
4071
+ const { container: slotContainer, markerRange } = loadingShellTarget()
4072
+ // `loadingShellTarget` degrades to <body> when the live document has no page slot - what a
4073
+ // CLIENT layout leaves behind, its slot markers consumed by the Preact render. Painting there
4074
+ // replaces the WHOLE live body with an inert copy: no preserved islands, no reused layout DOM.
4075
+ // When the incoming slot sits inside an island the live document also mounts, the paint IS
4076
+ // scopable after all - graft through that island (searchparams-reuse-loading reuses a
4077
+ // prefetched loading state under a client layout). Otherwise wait for the payload, which
4078
+ // grafts through `swapBody` with island preservation intact.
4079
+ const scopedOwner =
4080
+ !markerRange && slotContainer === document.body
4081
+ ? liveOwnerOfIncomingSlot(doc, boundary ?? suspense ?? null)
4082
+ : null
4083
+ if (!markerRange && slotContainer === document.body && !scopedOwner) return false
4084
+ const container = scopedOwner ?? slotContainer
3698
4085
  const fragment = document.createDocumentFragment()
3699
4086
  // The streamed document can already contain an ancestor layout that resolved before the
3700
4087
  // loading boundary. Keep that prefix when painting the fallback: replacing the target
@@ -3716,6 +4103,15 @@ function showLoadingShell(
3716
4103
  // nothing this paint could put on screen.
3717
4104
  null
3718
4105
  if (!sourceNodes) return false
4106
+ // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
4107
+ // bar the instant this returns true), so the window route state must reflect the
4108
+ // DESTINATION before any island reads useParams - otherwise usePathname and useParams
4109
+ // diverge and a history entry captures the new URL with the OLD params.
4110
+ const shellRoute = predictedRoute ?? documentRouteState(doc)
4111
+ if (shellRoute) window.__PNEXT_ROUTE__ = shellRoute
4112
+ // Past every bailout: this paint puts the destination on screen and commits the
4113
+ // navigation, so its sheets go in with it rather than waiting for the payload.
4114
+ void installStylesheets(doc)
3719
4115
  for (const node of sourceNodes) fragment.append(document.importNode(node, true))
3720
4116
  // A STATIC STAGE paints committed content, so its fallbacks land bare — the
3721
4117
  // wrappers are stripped from the COPY (the source doc keeps them, so the
@@ -3749,6 +4145,25 @@ function liveIslandOwner(node: Element): LiveIslandRoot | null {
3749
4145
  return root?.__pnextLive ? root : null
3750
4146
  }
3751
4147
 
4148
+ /**
4149
+ * The MOUNTED island that owns the incoming document's page slot, when the live document
4150
+ * mounts the same one. It is the paint target a consumed slot leaves behind: the shell
4151
+ * re-renders through the island rather than over the whole body.
4152
+ */
4153
+ function liveOwnerOfIncomingSlot(doc: Document, boundary: Element | null): LiveIslandRoot | null {
4154
+ // A shell that suspended ABOVE its page slot ships no slot at all - the slot rides in the
4155
+ // resolved chunk. The boundary itself then names the island the paint belongs to.
4156
+ const incoming = (doc.getElementById('pnext-page') ?? boundary)?.closest(
4157
+ 'pnext-client[data-pnext-client]',
4158
+ )
4159
+ const id = incoming?.getAttribute('data-pnext-client')
4160
+ if (!id) return null
4161
+ const live: LiveIslandRoot | null = document.querySelector(
4162
+ `pnext-client[data-pnext-client="${CSS.escape(id)}"]`,
4163
+ )
4164
+ return live?.__pnextLive ? live : null
4165
+ }
4166
+
3752
4167
  /** `live`'s counterpart in an incoming document, matched on island id. */
3753
4168
  function incomingIslandFor(doc: Document, live: LiveIslandRoot): Element | null {
3754
4169
  const id = live.getAttribute('data-pnext-client')
@@ -3774,13 +4189,101 @@ function mountPaintedIslands(doc: Document) {
3774
4189
  * layout the live tree never rendered, a parallel-route slot beside the page) - those nodes
3775
4190
  * would be dropped. When the incoming stage carries such structure, take the real graft
3776
4191
  * path instead: `swapBody`, so root-layout DOM identity survives.
4192
+ *
4193
+ * Exported for the DOM-level unit test.
3777
4194
  */
3778
- function commitStaticStage(html: string, sequence: number, target: URL): boolean {
4195
+ export function commitStaticStage(
4196
+ html: string,
4197
+ sequence: number,
4198
+ target: URL,
4199
+ /**
4200
+ * Whether the server declared this a postponed shell. Under cacheComponents, Next treats this
4201
+ * as the segment-cache commit and may paint its application fallback. Ordinary apps still need
4202
+ * destination content beside the holes or an explicit loading.js boundary.
4203
+ */
4204
+ postponedShell = false,
4205
+ ): boolean {
3779
4206
  if (sequence !== navigationSequence) return false
4207
+ // cacheComponents' postponed stage is a real segment-cache commit, including
4208
+ // an application Suspense fallback such as Next's mismatching-prefetch shell.
4209
+ // Ordinary apps retain the stricter rule that prevented their root splash
4210
+ // from painting merely because a shell arrived.
4211
+ if (!staticStageIsRealContent(html, postponedShell && activityBfcacheEnabled())) return false
3780
4212
  if (paintStaticStageSubtree(html)) return true
3781
4213
  return showLoadingShell(html, sequence, target, undefined, true)
3782
4214
  }
3783
4215
 
4216
+ /**
4217
+ * True when a cached static stage is the destination's REAL content rather than an app shell
4218
+ * still waiting on its holes. `allowWithoutBoundary` exists for the fully prerendered stage,
4219
+ * which carries no boundary at all; a stage whose page content is NOTHING but an unresolved
4220
+ * `<Suspense>` fallback is a placeholder the dynamic payload replaces, and only a `loading.js`
4221
+ * boundary may paint that early - exactly the rule the loading-shell path applies.
4222
+ *
4223
+ * A partially static stage - real content BESIDE its holes - is the destination's prerendered
4224
+ * output, which Next paints as soon as it has it (segment-cache "serves cached static segments
4225
+ * instantly on the second navigation").
4226
+ */
4227
+ function staticStageIsRealContent(html: string, trustedPostponedShell = false): boolean {
4228
+ // Cheap reject: no boundary wire form in the markup means nothing is unresolved.
4229
+ if (!html.includes('data-pnext-suspense') && !html.includes('data-pnext-hole')) return true
4230
+ if (trustedPostponedShell) return true
4231
+ if (typeof DOMParser === 'undefined') return true
4232
+ const doc = new DOMParser().parseFromString(html, 'text/html')
4233
+ materializeClientIslandMarkers(doc)
4234
+ // Boundaries whose chunk already streamed resolve here; whatever marker survives is a hole
4235
+ // the dynamic stage still owes.
4236
+ materializeStreamedSegments(doc)
4237
+ // A hole stamped with the loading depth is a loading.js boundary, which may paint here for
4238
+ // the same reason its marker form may: it is the wait Next itself shows.
4239
+ const unresolved = [
4240
+ ...doc.querySelectorAll(`pnext-hole[data-pnext-hole]:not([${LOADING_DEPTH_ATTRIBUTE}])`),
4241
+ ...[...doc.querySelectorAll('pnext-suspense[data-pnext-suspense]')].filter(
4242
+ marker => !isLoadingBoundaryMarker(marker),
4243
+ ),
4244
+ ]
4245
+ return unresolved.length === 0 || stageCarriesContentBesideHoles(doc)
4246
+ }
4247
+
4248
+ /** Elements that carry no page content of their own: wire hosts and document plumbing. */
4249
+ const STAGE_STRUCTURAL_ELEMENTS = new Set([
4250
+ 'script',
4251
+ 'template',
4252
+ 'style',
4253
+ 'link',
4254
+ 'pnext-suspense',
4255
+ 'pnext-hole',
4256
+ ])
4257
+
4258
+ /**
4259
+ * True when the stage renders something of the destination's own OUTSIDE its unresolved
4260
+ * boundaries - the partially static (PPR) shape. A stage whose page slot holds only boundary
4261
+ * fallbacks is an app shell and keeps waiting.
4262
+ */
4263
+ function stageCarriesContentBesideHoles(doc: Document): boolean {
4264
+ const pageSlot = doc.getElementById('pnext-page')
4265
+ if (
4266
+ !pageSlot &&
4267
+ doc.querySelector(
4268
+ 'pnext-client :is(pnext-hole[data-pnext-hole],pnext-suspense[data-pnext-suspense])',
4269
+ )
4270
+ ) {
4271
+ // A boundary above the page slot postpones the client root that owns the page itself.
4272
+ // Wrapper/static-child markup around that hole is not an independently paintable PPR
4273
+ // segment; the page slot only materializes with the continuation.
4274
+ return false
4275
+ }
4276
+ const slot = pageSlot ?? doc.body
4277
+ if (!slot) return false
4278
+ for (const element of slot.querySelectorAll('*')) {
4279
+ if (STAGE_STRUCTURAL_ELEMENTS.has(element.localName)) continue
4280
+ if (element.closest('pnext-suspense[data-pnext-suspense], pnext-hole[data-pnext-hole]'))
4281
+ continue
4282
+ return true
4283
+ }
4284
+ return false
4285
+ }
4286
+
3784
4287
  /**
3785
4288
  * Replace every `<pnext-suspense>` fallback wrapper with the fallback itself. The renderer
3786
4289
  * wraps a boundary's fallback so the streaming runtime can promote the resolved chunk over
@@ -3815,6 +4318,9 @@ export function paintStaticStageSubtree(html: string): boolean {
3815
4318
  materializeClientIslandMarkers(doc)
3816
4319
  materializeStreamedSegments(doc)
3817
4320
  if (!staticStageFrameGrows(doc)) return false
4321
+ // This paint COMMITS the navigation, so the destination's sheets belong on the document
4322
+ // now; the commit below re-reads them from its own doc and waits out these loads.
4323
+ void installStylesheets(doc)
3818
4324
  unwrapSuspenseFallbacks(doc)
3819
4325
  // A painted static stage IS a committed navigation (see showLoadingShell):
3820
4326
  // the window route state must describe the destination before any island
@@ -3907,13 +4413,31 @@ function frameDescriptor(element: Element): string {
3907
4413
  /** Attribute the renderer stamps with a loading boundary's guarded URL depth. */
3908
4414
  const LOADING_DEPTH_ATTRIBUTE = 'data-pnext-loading-depth'
3909
4415
 
4416
+ /**
4417
+ * True when a shell's boundary is a `loading.js` one — the ONLY boundary Next may swap in
4418
+ * before the navigation payload lands. The renderer stamps the URL depth on exactly those
4419
+ * markers, so the attribute IS the signal: a plain `<Suspense>` the app wrote in a layout
4420
+ * (or in the page) carries none, and its fallback belongs to the incoming render, not to
4421
+ * the wait for it. Painting one over the departing page empties the body a whole request
4422
+ * early, where Next keeps the previous route interactive.
4423
+ *
4424
+ * The inline-suspense wire form (`<pnext-hole>`, rewritten from preact's stream comments)
4425
+ * gets the same stamp lifted onto it from the fallback's leading depth marker, so a
4426
+ * loading.js boundary proves itself in both wire forms; an app `<Suspense>` in either
4427
+ * carries nothing and never paints a fallback.
4428
+ */
4429
+ function isLoadingBoundaryMarker(marker: Element | null): boolean {
4430
+ return marker?.hasAttribute(LOADING_DEPTH_ATTRIBUTE) === true
4431
+ }
4432
+
3910
4433
  /**
3911
4434
  * True when the loading boundary carried by a shell owns a segment INSIDE the subtree this
3912
4435
  * navigation changes, so its fallback may paint. A loading.js boundary re-arms only when
3913
4436
  * the segment it directly guards changes; the server stamps each boundary's URL depth on
3914
4437
  * its marker, and when the live and target children paths diverge DEEPER than that the
3915
4438
  * 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.
4439
+ * state Next never shows. A STATIC STAGE paints committed content rather than a fallback,
4440
+ * so its (possibly absent) marker imposes no such gate.
3917
4441
  */
3918
4442
  function loadingBoundaryChanges(marker: Element | null, target: URL): boolean {
3919
4443
  const raw = marker?.getAttribute(LOADING_DEPTH_ATTRIBUTE)
@@ -3946,6 +4470,10 @@ function unsettledPrefetchDeadline(): Promise<null> {
3946
4470
  return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
3947
4471
  }
3948
4472
 
4473
+ // Kept copies older than REVALIDATE_AFTER_MS that core commits at once, then refreshes in place.
4474
+ const keptCopies = new WeakSet<PrefetchedPage>()
4475
+ const REVALIDATE_AFTER_MS = 2_000
4476
+
3949
4477
  async function pageForNavigation(
3950
4478
  url: URL,
3951
4479
  options: SoftNavigateOptions = {},
@@ -3955,7 +4483,7 @@ async function pageForNavigation(
3955
4483
  * out. Unlike `onShell` this markup is not a loading fallback but the route's real static
3956
4484
  * content, so it paints with or without a boundary.
3957
4485
  */
3958
- onStaticStage?: (html: string) => void,
4486
+ onStaticStage?: (html: string, postponedShell?: boolean) => void,
3959
4487
  /** The segment-cache hit `softNavigate` already looked up for this URL. */
3960
4488
  segmentHit?: SegmentCacheHit | null,
3961
4489
  /**
@@ -3963,6 +4491,8 @@ async function pageForNavigation(
3963
4491
  * paint could swap the live one. Cached entries are matched against it, never the live one.
3964
4492
  */
3965
4493
  departureNavState: DocumentNavState = currentNavState(),
4494
+ /** The rendered origin route; unlike location this still names the departure on popstate. */
4495
+ departureUrl: string = locationKey(),
3966
4496
  /**
3967
4497
  * This navigation may ask for the `/_page` frame alone when the policy says the layout
3968
4498
  * chain is already in hand. Decided by `softNavigate` (it owns the departing URL/state)
@@ -3975,14 +4505,14 @@ async function pageForNavigation(
3975
4505
  // At most one static stage paints per navigation: an attached prefetch's
3976
4506
  // shell and a segment-cache hit are two views of the same content.
3977
4507
  let staticStagePainted = false
3978
- const paintStaticStage = (html: string) => {
4508
+ const paintStaticStage = (html: string, postponedShell = false) => {
3979
4509
  if (staticStagePainted) return
3980
4510
  staticStagePainted = true
3981
4511
  // A cached stage with parallel slots has dynamic continuations beside the page. Those cannot
3982
4512
  // be reconstructed from a page-only frame, so this navigation needs the whole target document.
3983
4513
  if (html.includes('data-pnext-slot=') || slotStateSensitive(departureNavState, html))
3984
4514
  pageFrame.eligible = false
3985
- onStaticStage?.(html)
4515
+ onStaticStage?.(html, postponedShell)
3986
4516
  }
3987
4517
  const cached = prefetchEntriesForNavigation(key).find(
3988
4518
  entry =>
@@ -4010,11 +4540,21 @@ async function pageForNavigation(
4010
4540
  cached.settled || cached.full
4011
4541
  ? await cached.page
4012
4542
  : await Promise.race([cached.page, unsettledPrefetchDeadline()])
4013
- if (page && !page.shellOnly) return page
4543
+ if (page && !page.shellOnly) {
4544
+ if (
4545
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4546
+ !prefetchStaleTimePolicy &&
4547
+ now - cached.time > REVALIDATE_AFTER_MS
4548
+ )
4549
+ keptCopies.add(page)
4550
+ return page
4551
+ }
4014
4552
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
4015
4553
  // the navigation issued no duplicate fetch, so paint the static stage it landed and let
4016
4554
  // the dynamic stage stream in below.
4017
- if (page?.shellOnly) paintStaticStage(page.html)
4555
+ // A shell-only prefetch IS the server's postponed shell for this URL (its headers are what
4556
+ // `shellOnly` reads), so it paints like the PPR stage it is.
4557
+ if (page?.shellOnly) paintStaticStage(page.html, true)
4018
4558
  if (!page) {
4019
4559
  for (const full of [true, false]) {
4020
4560
  const cacheKey = prefetchCacheKey(key, full)
@@ -4054,7 +4594,8 @@ async function pageForNavigation(
4054
4594
  // Partial paint (Segment-M2 fix-forward 1): a cached static segment that may
4055
4595
  // NOT commit on its own still paints now, so the route's static content is on
4056
4596
  // screen while the dynamic stage below streams the remainder in.
4057
- if (segmentHit && !segmentHit.networkFree) paintStaticStage(segmentHit.html)
4597
+ if (segmentHit && !segmentHit.networkFree)
4598
+ paintStaticStage(segmentHit.html, segmentHit.postponedShell === true)
4058
4599
  // Per-segment navigation: `/_page` alone, composed with the cached layout.
4059
4600
  // Null (policy declined, miss, anything unexpected) falls through to the
4060
4601
  // whole-document fetch below unchanged.
@@ -4062,12 +4603,14 @@ async function pageForNavigation(
4062
4603
  ? await fetchPageFrameNavigation(url, {
4063
4604
  navState: options.navState ?? departureNavState,
4064
4605
  sameUrl: pageFrame.sameUrl,
4606
+ fromUrl: departureUrl,
4065
4607
  }).catch(() => null)
4066
4608
  : null
4067
4609
  const page =
4068
4610
  framed ??
4069
4611
  (await fetchPage(url.href, {
4070
4612
  navState: options.navState ?? departureNavState,
4613
+ fromUrl: departureUrl,
4071
4614
  onShell,
4072
4615
  }).catch(() => null))
4073
4616
  // Visited-page seeding: the navigation response is as fresh as any prefetch —
@@ -4197,15 +4740,28 @@ function seedNavigationEntry(
4197
4740
  }
4198
4741
 
4199
4742
  /**
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
4743
+ * The document's own PRE-HYDRATION shell, stashed by the document bootstrap before island
4744
+ * hydration (or by the streaming runtime if a continuation promotes first). `captureHardLoad`
4745
+ * otherwise sees the LIVE DOM at the load event - after every
4202
4746
  * 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.
4747
+ * settled and `sliceShell` finds no cut.
4205
4748
  */
4206
4749
  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
4750
+ const stashed = (window as { __PNEXT_SHELL_HTML__?: string }).__PNEXT_SHELL_HTML__
4751
+ // If a promotion script won the capture race, one or more dynamic continuations already sit in
4752
+ // the markup. Those carry the loaded URL's resolved content, so anything replaying this stage -
4753
+ // a sibling param reusing it across an empty vary set - would paint that URL's data. Drop any
4754
+ // carriers; the document-bootstrap zero-stream case simply has none.
4755
+ return stashed && stripStreamChunks(stashed)
4756
+ }
4757
+
4758
+ /** `html` without the hidden `<div data-pnext-stream>` carriers of its streamed continuations. */
4759
+ function stripStreamChunks(html: string): string {
4760
+ const doc = new DOMParser().parseFromString(html, 'text/html')
4761
+ const chunks = doc.querySelectorAll('div[hidden][data-pnext-stream]')
4762
+ if (!chunks.length) return html
4763
+ for (const chunk of chunks) chunk.remove()
4764
+ return `<!doctype html>${doc.documentElement.outerHTML}`
4209
4765
  }
4210
4766
 
4211
4767
  /**
@@ -4252,12 +4808,14 @@ function recordNavigationSegment(
4252
4808
  if (hardLoad && runtimeDocument) return
4253
4809
  const hint = documentStaticHintFromHtml(page.html)
4254
4810
  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
4811
+ // A document resumed from a BAKED SHELL is a prerender, and the server published that
4812
+ // shell's vary set alongside the flag, so the entry keys across the route's params. Both
4813
+ // halves are required: the flag alone (with no stage in hand) proves nothing, and a stage
4814
+ // without it is a sliced dynamic stream. The stage is whichever the router holds - a hard
4815
+ // load's stashed pre-hydration prefix, or the slice of a streamed navigation response,
4816
+ // which cuts at exactly the boundary the server postponed at. The entry stays INCOMPLETE,
4817
+ // so a navigation onto it still fetches its own dynamic remainder.
4818
+ const bakedStaticStage = (staticStage ?? shell) !== null && hint?.staticStage === true
4261
4819
  const isStatic = page.segmentPrerendered === true || hint?.isStatic === true || bakedStaticStage
4262
4820
  if (shell === null && !isStatic) return
4263
4821
  const staleTimeMs = prefetchStaleTimeMs({
@@ -4289,6 +4847,7 @@ function recordNavigationSegment(
4289
4847
  complete: shell === null,
4290
4848
  static: isStatic,
4291
4849
  runtime: false,
4850
+ postponedShell: bakedStaticStage,
4292
4851
  })
4293
4852
  }
4294
4853
 
@@ -4384,8 +4943,14 @@ export function navStateKey(state: DocumentNavState): string {
4384
4943
  let navigationSequence = 0
4385
4944
  let suppressImmediateRefresh = false
4386
4945
 
4946
+ /** Current navigation generation, exposed so DOM-level paint tests retain the stale-work guard. */
4947
+ export function currentNavigationSequence(): number {
4948
+ return navigationSequence
4949
+ }
4950
+
4387
4951
  export async function softNavigate(href: string, options: SoftNavigateOptions = {}) {
4388
4952
  emitNavigationStart()
4953
+ const departingEntrySrc = entryScriptSrc(document)
4389
4954
  // The entry being left (on popstate, history already points at the target,
4390
4955
  // so the live document's entry id is the tracked active one).
4391
4956
  const departingBfcacheId = options.pop ? activeBfcacheId : (historyBfcacheId() ?? activeBfcacheId)
@@ -4401,27 +4966,50 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4401
4966
  // subtree (commitStaticStage) swaps the live `#__PNEXT_NAV_STATE__` for the
4402
4967
  // destination's, and reading it after that would report the incoming path as
4403
4968
  // the previous one (mis-classifying the nav as slot-only, killing the scroll).
4404
- const previousChildrenPath = currentNavState().children
4405
4969
  // The DEPARTING parallel-route state, likewise read before anything can paint. Every "may
4406
4970
  // this cached entry serve this navigation?" question is asked about the state the navigation
4407
4971
  // STARTED from. An optimistic loading-shell paint swaps the live `#__PNEXT_NAV_STATE__` for
4408
4972
  // the destination's, so re-reading after it would compare the navigation's own prefetch
4409
4973
  // against the target's state and reject it - re-fetching a route it had already prefetched.
4410
4974
  const departureNavState = currentNavState()
4975
+ const previousChildrenPath = departureNavState.children
4976
+ // `next-url` names the children tree the current document actually renders,
4977
+ // which can differ from the browser URL after middleware rewrites. Keep this
4978
+ // separate from `departingRouteKey`: the latter is intentionally the visible
4979
+ // pathname+search used by history/document caches.
4411
4980
  // Back/forward cache (Next's bfcache): the route being left, keyed by
4412
4981
  // pathname+search. On popstate `location` already points at the target, so
4413
4982
  // the departing route is the last committed one (`routerState.activeRouteKey`); on a
4414
4983
  // link/push nav it is simply the current location. Its stateful island roots
4415
4984
  // are stashed under this key below so returning to it (via link OR history
4416
4985
  // traversal) restores React state.
4417
- const departingRouteKey = options.pop
4418
- ? routerState.activeRouteKey
4419
- : bfRouteKey(location.pathname, location.search)
4986
+ const departingRouteKey = options.pop ? routerState.activeRouteKey : locationKey()
4987
+ const departureUrl = departureNavState.hostRender
4988
+ ? departingRouteKey
4989
+ : previousChildrenPath! + (departureNavState.childrenSearch ?? previousSearch)
4420
4990
  const url = resolveSoftUrl(href)
4421
4991
  if (!url) {
4422
4992
  hardNavigate(href, options.replace || options.pop)
4423
4993
  return
4424
4994
  }
4995
+ // Resolve a traversal's document in the same place as every other navigation source. popstate
4996
+ // supplies only the target history state; this chooser owns the decision between that entry's
4997
+ // immutable restore source and the normal cache/fetch path. `cachedPage` remains an explicit
4998
+ // injection point for focused DOM tests and callers that already hold a completed source.
4999
+ const entryRestorePage = options.pop
5000
+ ? entryDocCache.get(historyState().__pnextEntry as string)
5001
+ : undefined
5002
+ // A hard-loaded streaming document is cached from its immutable pre-hydration source. When that
5003
+ // source contains a boundary whose continuation had not parsed yet, it is a static/loading stage,
5004
+ // not a complete history document. Committing it on pop would bypass pageForNavigation entirely,
5005
+ // so no request could ever deliver the missing continuation and the entry would remain loading.
5006
+ // Keep explicit cachedPage injections unchanged for focused callers, but make an incomplete
5007
+ // entry-bound source fall through to the ordinary pop fetch.
5008
+ const restorePage =
5009
+ options.cachedPage ??
5010
+ (entryRestorePage && !streamHasPendingHole(entryRestorePage.html)
5011
+ ? entryRestorePage
5012
+ : undefined)
4425
5013
  // Client-instrumentation transition hook (Next's onRouterTransitionStart).
4426
5014
  ;(
4427
5015
  window as { __PNEXT_ON_ROUTER_TRANSITION_START__?: (href: string, kind: string) => void }
@@ -4457,6 +5045,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4457
5045
  // into the live body. Capturing later would save the target's fallback as
4458
5046
  // the previous history entry and restore a permanently stuck "Loading...".
4459
5047
  if (!options.pop) saveScrollPosition()
5048
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
5049
+ entryScroll.set(routerState.renderedEntryId, [window.scrollX, window.scrollY])
4460
5050
  if (departingBfcacheId) saveFormState(departingBfcacheId)
4461
5051
  // Snapshot the departing page's live island roots NOW, before a loading shell
4462
5052
  // can paint over (and detach) them below — they are stashed for back/forward
@@ -4475,6 +5065,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4475
5065
  // shallow same-entry move in onPopState and get dropped, stranding the UI on the
4476
5066
  // half-committed target. The commit below reuses the id.
4477
5067
  let optimisticEntryId: string | undefined
5068
+ let optimisticBfcacheId: string | undefined
4478
5069
  // `silent` moves the address bar without broadcasting: used before the tree is painted, where a
4479
5070
  // location broadcast would render the destination URL against the departing route's params.
4480
5071
  const pushOptimisticUrl = (silent = false) => {
@@ -4482,7 +5073,21 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4482
5073
  if (url.pathname === location.pathname && url.search === location.search) return
4483
5074
  optimisticallyPushed = true
4484
5075
  optimisticEntryId = routerState.renderedEntryId = newEntryId()
4485
- const shellState = { ...historyState(), __pnextEntry: optimisticEntryId }
5076
+ // The loading/static stage publishes the destination URL immediately. Give
5077
+ // that render the destination's state identity too; otherwise useRouter
5078
+ // observes the new pathname with the departing bfcacheId, and the final
5079
+ // same-URL broadcast cannot make a keyed leaf reset.
5080
+ optimisticBfcacheId = nextBfcacheIdForNavigation(url, previousPathname)
5081
+ // A PAINTED optimistic entry's document is what is on screen from here on; keep the
5082
+ // live-entry pointer in step, or a traversal in this window snapshots the DESTINATION's
5083
+ // DOM under the departing entry's id and destroys that entry's form-state capture. The
5084
+ // silent (pre-paint) push leaves the pointer alone — the departing DOM is still live.
5085
+ if (!silent) activeBfcacheId = optimisticBfcacheId
5086
+ const shellState = {
5087
+ ...historyState(),
5088
+ __pnextEntry: optimisticEntryId,
5089
+ [HISTORY_BFCACHE_ID_KEY]: optimisticBfcacheId,
5090
+ }
4486
5091
  const move = () => {
4487
5092
  if (options.replace) history.replaceState(shellState, '', url.href)
4488
5093
  else history.pushState(shellState, '', url.href)
@@ -4505,12 +5110,14 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4505
5110
  // server-side, and re-painting would replace a committed loading state with a second,
4506
5111
  // different one. A segment's loading fallback commits ONCE per navigation.
4507
5112
  let cachedStagePainted = false
5113
+ const devSoftNavigation = nextDevDocument()
4508
5114
  const onShell =
4509
- options.cachedPage || options.pop || refreshLike
5115
+ restorePage || options.pop || refreshLike
4510
5116
  ? undefined
4511
5117
  : (shellHtml: string) => {
4512
5118
  if (cachedStagePainted) return
4513
- if (!showLoadingShell(shellHtml, sequence, url)) return
5119
+ if (devSoftNavigation) return
5120
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return
4514
5121
  // A loading boundary is a committed navigation state.
4515
5122
  pushOptimisticUrl()
4516
5123
  scheduleNavigationScroll(url, options)
@@ -4519,7 +5126,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4519
5126
  // whether the generic loading shell should paint (a real cached static segment is strictly
4520
5127
  // better) and, in pageForNavigation, whether the navigation commits network-free.
4521
5128
  const segmentHit =
4522
- options.cachedPage || options.pop || refreshLike || slotStateSensitive(currentNavState())
5129
+ restorePage || options.pop || refreshLike || slotStateSensitive(currentNavState())
4523
5130
  ? null
4524
5131
  : (segmentCachePolicy?.take({
4525
5132
  pathname: url.pathname,
@@ -4529,8 +5136,9 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4529
5136
  // Paint a cached STATIC STAGE — the route's own content, not a fallback — and
4530
5137
  // treat it as a committed navigation exactly like a loading shell.
4531
5138
  const onStaticStage = onShell
4532
- ? (html: string) => {
4533
- if (!commitStaticStage(html, sequence, url)) return
5139
+ ? (html: string, postponedShell = false) => {
5140
+ if (devSoftNavigation) return
5141
+ if (!commitStaticStage(html, sequence, url, postponedShell)) return
4534
5142
  cachedStagePainted = true
4535
5143
  pushOptimisticUrl()
4536
5144
  scheduleNavigationScroll(url, options)
@@ -4544,6 +5152,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4544
5152
  // paint a sibling's layout while the destination layout is still on the wire.
4545
5153
  if (
4546
5154
  onShell &&
5155
+ !devSoftNavigation &&
4547
5156
  !refreshLike &&
4548
5157
  !segmentHit &&
4549
5158
  !segmentCachePolicy?.needsLayoutFrameOnly?.({
@@ -4587,12 +5196,12 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4587
5196
  // The document is still valid data for its own route, so it stays in the bfcache instead of
4588
5197
  // dying with the navigation. Never for a cached entry: re-storing one resets its commit time.
4589
5198
  const abandonFetchedPage = (fetched: PrefetchedPage | null | undefined) => {
4590
- if (!fetched || options.cachedPage) return
5199
+ if (!fetched || restorePage) return
4591
5200
  const settled = new URL(fetched.finalUrl, location.href)
4592
5201
  storeBfDoc(bfRouteKey(settled.pathname, settled.search), fetched)
4593
5202
  }
4594
5203
  let page =
4595
- options.cachedPage ??
5204
+ restorePage ??
4596
5205
  (await pageForNavigation(
4597
5206
  url,
4598
5207
  { ...options, refreshLike },
@@ -4600,6 +5209,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4600
5209
  onStaticStage,
4601
5210
  segmentHit,
4602
5211
  departureNavState,
5212
+ departureUrl,
4603
5213
  pageFrame,
4604
5214
  ))
4605
5215
  if (sequence !== navigationSequence) return abandonFetchedPage(page)
@@ -4632,7 +5242,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4632
5242
  // refetch with the full-render header. A cached history entry has no server to refetch from
4633
5243
  // as far as this navigation is concerned - the document IS the entry's own render.
4634
5244
  if (
4635
- !options.cachedPage &&
5245
+ !restorePage &&
4636
5246
  skippedSegmentsUngraftable(
4637
5247
  doc,
4638
5248
  options.freshSegments || cachedStagePainted || (refreshLike && !options.pageRefresh),
@@ -4640,6 +5250,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4640
5250
  ) {
4641
5251
  const fullPage = await fetchPage(url.href, {
4642
5252
  navState: options.navState,
5253
+ fromUrl: departureUrl,
4643
5254
  fullRender: true,
4644
5255
  }).catch(() => null)
4645
5256
  if (sequence !== navigationSequence) return abandonFetchedPage(fullPage ?? page)
@@ -4675,14 +5286,33 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4675
5286
  // then paints styled content immediately instead of flashing unstyled HTML.
4676
5287
  const entrySrc = entryScriptSrc(doc)
4677
5288
  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)
5289
+ // A restored traversal with warm modules and settled stylesheets commits in the popstate
5290
+ // task itself: the browser (and Next's in-memory router) treat back/forward as synchronous,
5291
+ // so a reader immediately after history.back() must observe the target document. Awaiting
5292
+ // here — even already-resolved promises — pushes the swap at least a task later and loses
5293
+ // that footrace. Everything upstream of this point is synchronous when restorePage is set.
5294
+ const pendingStylesheets = installStylesheets(doc)
5295
+ let entryModule = entrySrc ? (entryModuleCache.get(entryModuleHref(entrySrc)) ?? null) : null
5296
+ let departingEntryModule = departingEntrySrc
5297
+ ? (entryModuleCache.get(entryModuleHref(departingEntrySrc)) ?? null)
5298
+ : null
5299
+ const warmPop =
5300
+ options.pop &&
5301
+ Boolean(restorePage) &&
5302
+ pendingStylesheets.length === 0 &&
5303
+ (entryModule !== null || !entrySrc) &&
5304
+ (departingEntryModule !== null || !departingEntrySrc)
5305
+ if (!warmPop) {
5306
+ ;[entryModule, departingEntryModule] = await Promise.all([
5307
+ entrySrc ? importEntry(entrySrc) : Promise.resolve(null),
5308
+ departingEntrySrc ? importEntry(departingEntrySrc) : Promise.resolve(null),
5309
+ Promise.all(pendingStylesheets),
5310
+ ])
5311
+ if (sequence !== navigationSequence) return abandonFetchedPage(page)
5312
+ }
4683
5313
 
4684
5314
  if (!options.pop) {
4685
- const bfcacheId = nextBfcacheIdForNavigation(targetUrl, previousPathname)
5315
+ const bfcacheId = optimisticBfcacheId ?? nextBfcacheIdForNavigation(targetUrl, previousPathname)
4686
5316
  const entryState = {
4687
5317
  ...historyState(),
4688
5318
  // The shell push already minted this entry's id; keep it so the resolved
@@ -4708,11 +5338,27 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4708
5338
  else history.pushState(entryState, '', committedHref)
4709
5339
  }
4710
5340
 
5341
+ // Install the final document's route snapshot before any preserved island is reconciled.
5342
+ // A full popstate intentionally does not publish its address-bar change while the departing
5343
+ // tree is still live; this snapshot keeps useParams paired with the URL when mountRoute renders
5344
+ // that island for the committed tree. Forward commits need the same ordering when no loading or
5345
+ // static stage painted an earlier destination snapshot.
5346
+ // Compare against the departing route before publishing the incoming route
5347
+ // state. Once window.__PNEXT_ROUTE__ is replaced, routeParamBoundaryChanged
5348
+ // would compare the destination with itself and hide every param transition.
5349
+ const parallelSlotsChanged = navSlotsChanged(doc)
5350
+ const remountPageIslands =
5351
+ (options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc)
5352
+ const committedRoute = documentRouteState(doc)
5353
+ if (committedRoute) window.__PNEXT_ROUTE__ = committedRoute
5354
+
4711
5355
  // Template semantics: a client template island REMOUNTS (fresh state) when
4712
5356
  // the segment it wraps navigates to a different path, and is preserved like
4713
5357
  // a layout island otherwise (refresh, search-param-only and slot-only navs).
4714
5358
  const remountTemplates = docNavStateChildren(doc, targetUrl.pathname) !== previousChildrenPath
4715
- const remountPageIslands = routeParamBoundaryChanged(doc)
5359
+ // Page effects are entry-scoped. In particular, an app's popstate-gated initializer must run
5360
+ // again on cached back; reattaching the old live page root skips that initializer. Different
5361
+ // children routes likewise mount fresh even when two pages happen to use the same island id.
4716
5362
  // Shared-layout state preservation: live island roots that render again in the incoming
4717
5363
  // document keep their DOM (and component state) across the swap. Server-segment
4718
5364
  // preservation keeps live layout DOM and refreshes only the page slot. Parallel-route slot
@@ -4728,10 +5374,30 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4728
5374
  options.freshSegments ||
4729
5375
  cachedStagePainted ||
4730
5376
  (refreshLike && !options.pageRefresh) ||
4731
- navSlotsChanged(doc)
5377
+ parallelSlotsChanged
4732
5378
  ? (clearSegmentPreserveTags(doc), [])
4733
5379
  : matchPreservedServerSegments(doc)
4734
5380
  const preservedIslands = matchPreservedIslands(doc, remountTemplates, remountPageIslands)
5381
+ // A missing live page marker alone is not enough: ordinary server layouts also consume their
5382
+ // marker after mount. The slotless lifecycle path is specifically for a preserved client-layout
5383
+ // island whose incoming placeholder owns the page marker that its live tree adopted.
5384
+ const slotlessClientRootLayout = hasSlotlessClientRootLayout(doc, preservedIslands)
5385
+ // A cacheComponents tree follows Next's Activity lifecycle: the inactive page
5386
+ // stays mounted. The explicit empty-page pass exists only for ordinary apps,
5387
+ // whose outgoing page must unmount while its DOM is still connected.
5388
+ // On a traversal, the browser has already fired popstate. A synthetic empty-page
5389
+ // render would remount layout effects against the departing URL and let them
5390
+ // consume that signal before the destination page can initialize from it. The
5391
+ // final mount still reconciles the outgoing page while its preserved shell is
5392
+ // connected. Forward navigations retain the explicit pass so their cleanup can
5393
+ // snapshot the departing page before the swap.
5394
+ const unmountSlotlessPage = slotlessClientRootLayout && !activityBfcacheEnabled() && !options.pop
5395
+ if (unmountSlotlessPage) {
5396
+ keySlotlessClientPage(
5397
+ doc,
5398
+ `${targetUrl.pathname}${targetUrl.search}|${historyBfcacheId() ?? ''}`,
5399
+ )
5400
+ }
4735
5401
  const preservedPage = matchPreservedClientPage(
4736
5402
  doc,
4737
5403
  (refreshLike || sameRouteQueryNav(doc)) && !options.remount,
@@ -4744,11 +5410,22 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4744
5410
  ...preservedIslands,
4745
5411
  ...preservedSegmentIslands(preservedSegments),
4746
5412
  ])
5413
+ // A client root layout can consume the page slot during hydration, leaving its route screens as
5414
+ // top-level island roots beside the preserved shell islands. Absence from #pnext-page must not
5415
+ // promote those unmatched screens into layout cache entries: they are page lifecycle owners and
5416
+ // must unmount while the live shell (and its scroll container) is still attached. The shared roots
5417
+ // are already identified above by matching an incoming placeholder and remain preserved.
4747
5418
  // Back/forward cache - stash BEFORE restore, so the fixed-size eviction sees the true entry
4748
5419
  // count (page 1 evicts on the 4th visit even though that same navigation restores page 2).
4749
5420
  // Keeping the departing roots alive and adding them to keptIslands makes the unmount below
4750
5421
  // skip them, so a later return restores their React state.
4751
- const departingRoots = departingLiveRoots.filter(root => !keptLiveIslands.has(root))
5422
+ const preserveInactiveTree = activityBfcacheEnabled()
5423
+ const departingRoots = preserveInactiveTree
5424
+ ? departingLiveRoots.filter(root => !keptLiveIslands.has(root))
5425
+ : []
5426
+ // Only shared-layout roots belong in pnext's live-root cache. Page roots must be unmounted by
5427
+ // the outgoing entry while still attached (so effect cleanups can read/save DOM state), then the
5428
+ // destination source mounts a fresh root after the swap.
4752
5429
  if (departingRouteKey && departingRouteKey !== targetRouteKey) {
4753
5430
  stashRouteRoots(departingRouteKey, departingRoots)
4754
5431
  }
@@ -4758,9 +5435,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4758
5435
  // cacheComponents (Next's link-navigation bfcache ships with the client segment cache); a
4759
5436
  // classic app remounts instead, so a re-revealed accordion starts closed again. History
4760
5437
  // traversal restores either way.
4761
- const bfcacheRestores = options.pop || segmentSchedulerEnabled()
4762
5438
  const restoredIslands =
4763
- refreshLike || !bfcacheRestores
5439
+ refreshLike || !preserveInactiveTree
4764
5440
  ? []
4765
5441
  : matchRouteCachedIslands(doc, targetRouteKey, preservedIslands.length, remountTemplates)
4766
5442
  const keptIslands = new Set<Element>([...keptLiveIslands, ...restoredIslands])
@@ -4769,68 +5445,183 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4769
5445
  // page slot is a marker-range proxy); the entry's unmount compares roots by
4770
5446
  // identity, so it rides the same set.
4771
5447
  if (preservedPage) keptIslands.add(preservedPage.root as unknown as Element)
5448
+ // Copy the screen as it is now, a loading or static stage included, before anything unmounts.
5449
+ // The resolved client-root commit shows this copy only while its first complete paint settles.
5450
+ const paintHoldScrollTop =
5451
+ document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5452
+ const paintHold = createNavigationPaintHold()
5453
+ // Wake route-keyed layout children while the departing DOM is still attached. Their layout
5454
+ // cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
4772
5455
  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)
5456
+ let focusedBeforeSwap: Element | null = null
5457
+ let navigationFocusTarget: HTMLElement | null = null
5458
+ try {
5459
+ if (unmountSlotlessPage && departingRouteKey && departingRouteKey !== targetRouteKey) {
5460
+ // Cover the lifecycle-only empty-page pass. This is the same outgoing screen clone used for
5461
+ // the final atomic commit, attached early enough that no empty intermediate frame can paint.
5462
+ attachNavigationPaintHold(paintHold, paintHoldScrollTop, sequence)
5463
+ await unmountSlotlessClientPage(
5464
+ doc,
5465
+ preservedIslands,
5466
+ departingEntryModule,
5467
+ departingRouteKey,
5468
+ )
5469
+ if (sequence !== navigationSequence) return
5470
+ }
5471
+ syncHeadMetadata(doc)
5472
+ // Next reconciles the DOM in place, so a focused element in a retained layout keeps focus
5473
+ // across a navigation. pnext's swap DETACHES preserved subtrees to graft them into the new
5474
+ // body, and detaching blurs - remember the focused node so it can be refocused.
5475
+ focusedBeforeSwap = document.activeElement
5476
+ swapBody(
5477
+ doc,
5478
+ [...preservedIslands, ...restoredIslands],
5479
+ preservedSegments,
5480
+ preservedPage,
5481
+ departingReusableBody,
5482
+ )
5483
+ attachNavigationPaintHold(paintHold, paintHoldScrollTop, sequence)
5484
+ // A client root layout adopts and dissolves the page-slot markers during
5485
+ // mount. Resolve Next's segment scroll target against the freshly swapped
5486
+ // document while those markers still identify the changed page.
5487
+ const slotOnlyNav =
5488
+ currentNavState().children === previousChildrenPath &&
5489
+ url.search === previousSearch &&
5490
+ // A changed pathname is only slot-only when the parallel-slot state
5491
+ // actually changed. This remains decidable when a body-owned client shell
5492
+ // has already removed the departing entry's route script.
5493
+ (targetUrl.pathname === previousPathname || parallelSlotsChanged) &&
5494
+ !remountPageIslands
5495
+ const navigationScrollOptions = {
5496
+ ...options,
5497
+ scroll:
5498
+ targetUrl.pathname !== previousPathname && !parallelSlotsChanged
5499
+ ? options.scroll
5500
+ : slotOnlyNav
5501
+ ? false
5502
+ : options.scroll,
5503
+ }
5504
+ // Resolve the changed segment while the freshly swapped document still has
5505
+ // its page markers. A body-owning client root dissolves those markers during
5506
+ // mount, after which the compat scroll walk can only see the broad shell.
5507
+ scheduleNavigationScroll(url, navigationScrollOptions)
5508
+ const focusedByNavigation = document.activeElement
5509
+ if (
5510
+ focusedByNavigation instanceof HTMLElement &&
5511
+ focusedByNavigation !== document.body &&
5512
+ focusedByNavigation !== document.documentElement &&
5513
+ focusedByNavigation !== focusedBeforeSwap
5514
+ ) {
5515
+ navigationFocusTarget = focusedByNavigation
5516
+ }
5517
+ stylesheetReconciler?.(doc)
5518
+ // Finish the stylesheet transaction in the same synchronous DOM turn as the body swap. A
5519
+ // selector waiter can observe the destination immediately after this stack unwinds; pruning in
5520
+ // the later post-mount phase let it catch the target node between its correct route sheet and a
5521
+ // stale asynchronous prune from the preceding navigation.
5522
+ // Core keeps the departing sheets while the paint hold still shows the departing screen.
5523
+ if (!url.hash && (stylesheetReconciler || !paintHold)) pruneStylesheets(doc)
5524
+ // The swapped document carries the render's parallel-route state; pin it
5525
+ // (plus the document itself) to this history entry so back/forward restores
5526
+ // what was actually shown, without a server round trip.
5527
+ storeNavState()
5528
+ cacheEntryDocument(page)
5529
+ // The committed document is a shown-route snapshot: a later full prefetch of
5530
+ // this route reads it from the bfcache instead of the network.
5531
+ storeBfDoc(targetRouteKey, page)
5532
+ // This route is now the committed one — the next navigation's departing key.
5533
+ routerState.activeRouteKey = targetRouteKey
5534
+ pingVisiblePrefetchLinks()
5535
+ // Browser form restoration is part of the traversal itself: fill the swapped (raw,
5536
+ // pre-hydration) controls in the same synchronous turn as the swap, so a reader right after
5537
+ // history.back() sees the values. The post-effects pass below still handles controls a
5538
+ // mount rebuilds, and only ever fills empty ones.
5539
+ if (options.pop) restoreFormState(historyBfcacheId())
5540
+
5541
+ if (page.p) (window as typeof window & { __PNEXT_PROPS__?: unknown }).__PNEXT_PROPS__ = page.p
5542
+ await entryModule?.mountRoute?.()
5543
+ // The mount can recreate the controls the pre-mount fill above populated. Re-fill before
5544
+ // flushClientEffects' settling FRAME below — deferring past it shows a traversal with
5545
+ // empty controls for a full frame, which a reader right after history.back() observes.
5546
+ if (options.pop && sequence === navigationSequence) restoreFormState(historyBfcacheId())
5547
+ // Compat batches layout-effect disposal to the end of the Preact commit. Let that disposal run
5548
+ // before usePathname wakes the preserved PageTransition's destination effect (which resets the
5549
+ // shared shell scroll position). The old screen and its scroll container are still connected.
5550
+ await flushClientEffects()
5551
+ // Publish the committed URL while the paint hold still covers the new tree. Preserved client
5552
+ // layouts key their routed child on usePathname/useSearchParams; waking that store is what gives
5553
+ // the outgoing child a real cleanup and the destination a fresh initializer. Keeping the hold
5554
+ // through the following frame makes that keyed replacement atomic in dev as well as production.
5555
+ if (sequence === navigationSequence) {
5556
+ // Commit listeners run first: loaders may deliberately remain visible until the location
5557
+ // subscriber observes the matching tree (the same ordering as the former end-of-commit pair).
5558
+ emitNavigationCommit()
5559
+ emitLocationChange()
5560
+ // History traversal restores the popped entry's form state over the freshly mounted tree
5561
+ // (browser back/forward form restoration semantics). This runs BEFORE the paint hold's
5562
+ // settling frame below: mounting can recreate the controls the pre-mount fill populated,
5563
+ // and deferring the re-fill past the hold's rAF leaves a visibly empty window after the
5564
+ // traversal has committed.
5565
+ if (options.pop) {
5566
+ const targetBfcacheId = historyBfcacheId()
5567
+ restoreFormState(targetBfcacheId)
5568
+ if (targetBfcacheId) restoreFormStateWhenMounted(targetBfcacheId, sequence)
5569
+ }
5570
+ }
5571
+ } finally {
5572
+ await releaseNavigationPaintHold(paintHold, () => {
5573
+ if (!url.hash && !stylesheetReconciler && sequence === navigationSequence)
5574
+ pruneStylesheets(doc)
5575
+ })
4802
5576
  }
4803
- // History traversal restores the popped entry's form state over the freshly
4804
- // mounted tree (browser back/forward form restoration semantics).
5577
+ if (sequence !== navigationSequence) return
5578
+ // Mounting/location subscribers run after the initial pre-mount scroll action.
5579
+ // Reapply traversal coordinates once that work has settled; otherwise a page
5580
+ // effect can reset the restored position to zero after Back.
4805
5581
  if (options.pop) {
4806
- const targetBfcacheId = historyBfcacheId()
4807
- restoreFormState(targetBfcacheId)
4808
- if (targetBfcacheId) restoreFormStateWhenMounted(targetBfcacheId, sequence)
5582
+ await flushClientEffects()
5583
+ scheduleNavigationScroll(url, options)
5584
+ }
5585
+ restoreSwapFocus(focusedBeforeSwap, navigationFocusTarget)
5586
+ // Activity-preserved trees reconcile their keyed leaf against the new entry id
5587
+ // themselves. Replaying the departing DOM snapshot there would resurrect the
5588
+ // old leaf value after React correctly reset it; the fallback is only for the
5589
+ // ordinary remounting path where pnext has replaced layout-owned DOM.
5590
+ if (
5591
+ !preserveInactiveTree &&
5592
+ !options.pop &&
5593
+ departingBfcacheId &&
5594
+ previousPathname !== targetUrl.pathname
5595
+ ) {
5596
+ restoreSharedLayoutFormState(departingBfcacheId, previousPathname, targetUrl.pathname)
4809
5597
  }
4810
5598
  // Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
4811
5599
  // `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
4812
5600
  // still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
4813
5601
  // swap), so re-apply the departure snapshot as the controls appear - but only to the DOM
4814
5602
  // this swap replaced, never to a control a live island's own render owns (see
4815
- // islandOwnedControl).
4816
- else if (departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5603
+ // islandOwnedControl). The pop equivalent runs inside the commit above, ahead of the paint
5604
+ // hold's settling frame.
5605
+ if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
4817
5606
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
4818
5607
  }
5608
+ if (
5609
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
5610
+ keptCopies.has(page) &&
5611
+ !documentStaticHintFromHtml(page.html)?.isStatic
5612
+ )
5613
+ void revalidateCommittedPage(targetUrl, sequence, page)
5614
+ }
4819
5615
 
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()
5616
+ async function revalidateCommittedPage(url: URL, sequence: number, page: PrefetchedPage) {
5617
+ const fresh = await fetchPage(url.href, { fullRender: true }).catch(() => null)
5618
+ if (!fresh?.ok || fresh.html === page.html || sequence !== navigationSequence) return
5619
+ await softNavigate(url.href, {
5620
+ replace: true,
5621
+ scroll: false,
5622
+ refreshLike: true,
5623
+ cachedPage: fresh,
5624
+ })
4834
5625
  }
4835
5626
 
4836
5627
  function isBotUserAgent() {
@@ -4872,10 +5663,14 @@ const RESTORED_INPUT_TYPES = new Set([
4872
5663
  function formControls(): (HTMLInputElement | HTMLTextAreaElement)[] {
4873
5664
  return [
4874
5665
  ...document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input, textarea'),
4875
- ].filter(control =>
4876
- control instanceof HTMLTextAreaElement
4877
- ? true
4878
- : RESTORED_INPUT_TYPES.has(control.getAttribute('type')?.toLowerCase() ?? ''),
5666
+ ].filter(
5667
+ control =>
5668
+ // The navigation paint hold is a paint-only CLONE of the departing screen: its copied
5669
+ // controls would shift indices and trip the count guard during the exact window the
5670
+ // synchronous pop restore runs in.
5671
+ !control.closest('[data-pnext-navigation-paint-hold]') &&
5672
+ (control instanceof HTMLTextAreaElement ||
5673
+ RESTORED_INPUT_TYPES.has(control.getAttribute('type')?.toLowerCase() ?? '')),
4879
5674
  )
4880
5675
  }
4881
5676
 
@@ -4949,8 +5744,12 @@ function restoreSharedLayoutFormState(
4949
5744
  const target = targetPathname.split('/').filter(Boolean)
4950
5745
  for (const [index, control] of controls.entries()) {
4951
5746
  if (elementInPageSlot(control)) continue
5747
+ // Only controls rendered by the layout island itself belong to this
5748
+ // shared scope. A nested page island may sit inside that host after it
5749
+ // adopts the page slot, but its bfcacheId intentionally changes on a fresh
5750
+ // push and its keyed form must be allowed to reset.
4952
5751
  const raw = control
4953
- .closest('pnext-client[data-pnext-layout-segments]')
5752
+ .closest('pnext-client[data-pnext-client]')
4954
5753
  ?.getAttribute('data-pnext-layout-segments')
4955
5754
  if (!raw) continue
4956
5755
  const depth = (JSON.parse(raw) as { depth?: number }).depth
@@ -5095,7 +5894,7 @@ function onLinkIntent(event: Event) {
5095
5894
  // touches roots no earlier pass mounted) and re-sync the router's bookkeeping.
5096
5895
  function onPageShow(event: PageTransitionEvent) {
5097
5896
  if (!event.persisted) return
5098
- routerState.activeRouteKey = bfRouteKey(location.pathname, location.search)
5897
+ routerState.activeRouteKey = locationKey()
5099
5898
  const entryId = historyState().__pnextEntry
5100
5899
  if (typeof entryId === 'string') routerState.renderedEntryId = entryId
5101
5900
  // A mount claim (mountIslandOnce) left in flight by the freeze can never
@@ -5132,18 +5931,13 @@ export function onPopState() {
5132
5931
  // (what was on screen when the entry was active), not the current
5133
5932
  // document's: from the entry cache when possible, else by re-rendering
5134
5933
  // server-side from the recorded slot state.
5135
- const cachedPage = entryDocCache.get(entryId)
5136
5934
  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
- )
5935
+ // Do not broadcast the address-bar change until softNavigate commits the popped document.
5936
+ // Persistent islands belong to the rendered tree, not to the speculative browser location:
5937
+ // publishing here can update their hook state while they are being detached, then make the
5938
+ // post-commit broadcast bail out as unchanged. softNavigate installs the cached document's route
5939
+ // snapshot before mountRoute and emits the location immediately after the navigation commit.
5940
+ void softNavigate(location.href, { pop: true, navState }).catch(() => location.reload())
5147
5941
  }
5148
5942
 
5149
5943
  const eagerLinks = new WeakSet<Element>()
@@ -5153,6 +5947,24 @@ const eagerLinks = new WeakSet<Element>()
5153
5947
  let visibleLinkObserver: IntersectionObserver | undefined
5154
5948
  let visibleLinkObserverDoc: Document | undefined
5155
5949
 
5950
+ /** Recompute visible Link prefetches against the newly committed URL/base tree. */
5951
+ export function pingVisiblePrefetchLinks() {
5952
+ for (const element of visiblePrefetchElements) {
5953
+ if (!element.isConnected) {
5954
+ visiblePrefetchElements.delete(element)
5955
+ prefetchedElements.delete(element)
5956
+ continue
5957
+ }
5958
+ const href = element.getAttribute('href')
5959
+ if (href) {
5960
+ void prefetchRoute(href, {
5961
+ element,
5962
+ full: isFullPrefetchLink(element),
5963
+ })
5964
+ }
5965
+ }
5966
+ }
5967
+
5156
5968
  // `load` links prefetch as soon as they appear; `visible` links when they
5157
5969
  // enter the viewport. Both arrive with the page or with later client renders,
5158
5970
  // so watch the whole document for additions.
@@ -5178,6 +5990,7 @@ function scanEagerPrefetchLinks(root: Element) {
5178
5990
  eagerLinks.add(link)
5179
5991
  if (mode === 'load') {
5180
5992
  const href = link.getAttribute('href')
5993
+ if (isElementVisible(link)) visiblePrefetchElements.add(link)
5181
5994
  if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
5182
5995
  continue
5183
5996
  }
@@ -5188,6 +6001,7 @@ function scanEagerPrefetchLinks(root: Element) {
5188
6001
  visibleLinkObserver ??= new IntersectionObserver(entries => {
5189
6002
  for (const entry of entries) {
5190
6003
  if (!entry.isIntersecting) {
6004
+ visiblePrefetchElements.delete(entry.target)
5191
6005
  // The link left the viewport (scrolled/hidden) — cancel its pending
5192
6006
  // prefetch task: queued work is dropped, an in-flight task stops
5193
6007
  // before its next phase, and the pending cache entry is evicted so
@@ -5196,6 +6010,7 @@ function scanEagerPrefetchLinks(root: Element) {
5196
6010
  if (task) cancelPrefetchTask(task)
5197
6011
  continue
5198
6012
  }
6013
+ visiblePrefetchElements.add(entry.target)
5199
6014
  const href = entry.target.getAttribute('href')
5200
6015
  if (href)
5201
6016
  void prefetchRoute(href, {
@@ -5206,6 +6021,7 @@ function scanEagerPrefetchLinks(root: Element) {
5206
6021
  })
5207
6022
  visibleLinkObserver.observe(link)
5208
6023
  if (isElementVisible(link)) {
6024
+ visiblePrefetchElements.add(link)
5209
6025
  const href = link.getAttribute('href')
5210
6026
  if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
5211
6027
  continue
@@ -5245,16 +6061,46 @@ export function installRouterFull() {
5245
6061
  // Seed the hard-loaded entry's ids BEFORE any island mounts — the hub already
5246
6062
  // did that (see ./index.ts installRouter); this tier picks up from there.
5247
6063
  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.
6064
+ // Next's initial RSC payload seeds the segment cache before request params resolve. PNext's hard
6065
+ // HTML load carries only the resolved continuation, so obtain the prerendered current-route stage
6066
+ // now and keep it as the cacheComponents segment seed. Default apps do no extra work.
6067
+ if (activityBfcacheEnabled()) void prefetchRoute(location.href, { currentUrl: true })
6068
+ // Bind every delayed hard-load operation to the document and history entry that installed this
6069
+ // runtime. `load` can fire after a fast soft navigation has already committed; consulting the
6070
+ // then-current location/state would file the original source under the destination entry key.
6071
+ const hardLoadRouteKey = locationKey()
6072
+ const hardLoadPathname = location.pathname
6073
+ let hardLoadHtml =
6074
+ (window as { __PNEXT_SHELL_HTML__?: string }).__PNEXT_SHELL_HTML__ ||
6075
+ `<!doctype html>${document.documentElement.outerHTML}`
6076
+ // The shell bootstrap can run before these settled, URL-specific scripts parse. A traversal must
6077
+ // restore them with the immutable entry source; otherwise a whole-page client component remounts
6078
+ // against the route/props globals of the page just left (for example Back to `[id]=1` renders 2).
6079
+ if (currentNavState().children !== hardLoadPathname)
6080
+ hardLoadHtml += document.getElementById('__PNEXT_NAV_STATE__')?.outerHTML ?? ''
6081
+ const hardLoadEntrySrc = entryScriptSrc(document)
6082
+ if (hardLoadEntrySrc) hardLoadHtml += `<script type="module" src="${hardLoadEntrySrc}"></script>`
6083
+ const hardLoadRestoreSource: PrefetchedPage = {
6084
+ html: hardLoadHtml,
6085
+ finalUrl: location.href,
6086
+ ok: true,
6087
+ p: (window as typeof window & { __PNEXT_PROPS__?: unknown }).__PNEXT_PROPS__,
6088
+ }
6089
+ const hardLoadStaticStage = documentStaticHintFromHtml(hardLoadRestoreSource.html)?.isStatic
6090
+ ? undefined
6091
+ : preHydrationShell()
6092
+ // Entry-document identity is needed as soon as navigation can start, not at window.load.
6093
+ cacheEntryDocument(hardLoadRestoreSource)
6094
+ const hardLoadEntryId = historyState().__pnextEntry
6095
+ // The source snapshot is already entry-bound above. Defer only navigation-cache seeding until
6096
+ // load, when the original response has completed its browser lifecycle.
5252
6097
  const captureHardLoad = () => {
5253
- const hardLoadedPage: PrefetchedPage = {
5254
- html: `<!doctype html>${document.documentElement.outerHTML}`,
5255
- finalUrl: location.href,
5256
- ok: true,
5257
- }
6098
+ // A fast navigation can commit before the original window load event. The live DOM then belongs
6099
+ // to another entry and must not be filed as this hard load's settled segment payload.
6100
+ if (historyState().__pnextEntry !== hardLoadEntryId) return
6101
+ const settledHtml = `<!doctype html>${document.documentElement.outerHTML}`
6102
+ const hardLoadHint = documentStaticHintFromHtml(settledHtml)
6103
+ const hardLoadRuntimePrefetch = htmlRuntimePrefetch(settledHtml)
5258
6104
  // A hard HTML load carries no `x-nextjs-stale-time` response header, so
5259
6105
  // seedNavigationEntry would fall to the dynamic default (0) and a fully
5260
6106
  // static page gets re-requested on the next navigation. The document inlines
@@ -5262,31 +6108,46 @@ export function installRouterFull() {
5262
6108
  // when the route is static, seed the TRUE static window (its own `cacheLife`
5263
6109
  // stale seconds, or the configured static default) and mark it prerendered so
5264
6110
  // 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
6111
+ const navigationSeedPage: PrefetchedPage = {
6112
+ ...hardLoadRestoreSource,
6113
+ html: settledHtml,
6114
+ }
6115
+ if (hardLoadHint?.isStatic) {
6116
+ navigationSeedPage.staleTimeSeconds = hardLoadHint.staleTime ?? shellStaleTimeMs() / 1000
6117
+ navigationSeedPage.segmentPrerendered = true
5269
6118
  }
5270
- cacheEntryDocument(hardLoadedPage)
6119
+ // Keep the entry-bound history document as immutable server source: ordinary
6120
+ // (non-cacheComponents) traversals must remount it, rather than restoring a
6121
+ // hydrated tree whose effect cleanup may already have mutated application
6122
+ // state. The navigation-data seed has a different contract. Once the hard
6123
+ // load settles, its resolved page data is fresh for staleTimes.dynamic and a
6124
+ // later Link back to this URL must be able to reuse it.
5271
6125
  // Visited-page seeding: the hard load is as fresh as a fetch — an immediate
5272
6126
  // soft navigation back to this URL within the dynamic window reuses it.
5273
6127
  seedNavigationEntry(
5274
- location.pathname + location.search,
5275
- location.pathname,
5276
- hardLoadedPage,
6128
+ hardLoadRouteKey,
6129
+ hardLoadPathname,
6130
+ navigationSeedPage,
5277
6131
  undefined,
5278
6132
  // A streaming document's static stage is unrecoverable from the settled
5279
6133
  // DOM; the pre-promotion stash is the only faithful copy.
5280
- hint?.isStatic ? undefined : preHydrationShell(),
6134
+ hardLoadStaticStage,
5281
6135
  true,
5282
6136
  )
5283
- storeBfDoc(location.pathname + location.search, hardLoadedPage)
6137
+ storeBfDoc(hardLoadRouteKey, navigationSeedPage)
6138
+ // A STREAMING route's pre-promotion shell always carries a pending hole, so the pop guard
6139
+ // in softNavigate rejects it and every traversal back to this entry refetches. The settled
6140
+ // DOM is this entry's only complete render — re-file it so history restoration stays
6141
+ // network-free. Complete (non-streaming) sources keep their immutable pre-hydration copy.
6142
+ if (streamHasPendingHole(hardLoadRestoreSource.html) && !streamHasPendingHole(settledHtml)) {
6143
+ cacheEntryDocument(navigationSeedPage)
6144
+ }
5284
6145
  // `prefetch = 'allow-runtime'`: the document that just loaded shows RESOLVED sampled
5285
6146
  // content, but its cacheable stage is the runtime-prefetch shell, which the hard load never
5286
6147
  // fetched. Ask the server for that payload so a later navigation back paints the sampled
5287
6148
  // content instead of the Suspense fallbacks.
5288
- if (documentRuntimePrefetch()) {
5289
- void prefetchRoute(location.href, { full: true, currentUrl: true })
6149
+ if (hardLoadRuntimePrefetch) {
6150
+ void prefetchRoute(hardLoadRestoreSource.finalUrl, { full: true, currentUrl: true })
5290
6151
  }
5291
6152
  }
5292
6153
  if (document.readyState === 'complete') captureHardLoad()
@@ -5317,5 +6178,7 @@ export function installRouterFull() {
5317
6178
  )
5318
6179
  window.addEventListener('popstate', onPopState)
5319
6180
  window.addEventListener('pageshow', onPageShow)
6181
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !navigationScrollAction)
6182
+ setNavigationScrollAction(coreNavigationScroll)
5320
6183
  watchEagerPrefetchLinks()
5321
6184
  }