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