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