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