@wular/pnext 0.1.1 → 0.1.3
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/package.json
CHANGED
package/reference/navigation.md
CHANGED
|
@@ -49,7 +49,7 @@ Requests use low network priority. At most four run at once, though the hover-in
|
|
|
49
49
|
|
|
50
50
|
## Soft navigation
|
|
51
51
|
|
|
52
|
-
Link clicks and router pushes swap the page in place instead of reloading the document, so shared chunks and CSS are never re-downloaded. A new page starts at the top, and back and forward stay soft and restore scroll.
|
|
52
|
+
Link clicks and router pushes swap the page in place instead of reloading the document, so shared chunks and CSS are never re-downloaded. A new page starts at the top, and back and forward stay soft and restore scroll. A dynamic page the router kept from a prefetch or a visit is reused as-is for 5 seconds. After that, the link paints the page's layout and loading fallbacks at once and streams fresh data in, so old data never shows; the frame stays reusable for five minutes. Back and forward restore the page as it was shown. Cross-origin targets, non-HTML responses, and fetch failures fall back to a full page load.
|
|
53
53
|
|
|
54
54
|
## Redirects and not found
|
|
55
55
|
|
|
@@ -14,6 +14,7 @@ import { resolveImport, workspacePackageRoots } from '../../resolve/imports'
|
|
|
14
14
|
import { findProxyFile, proxyRoutePatterns, type ProxyModule } from '../../routing/proxy'
|
|
15
15
|
import { cacheRoot } from '../boot/named-bin'
|
|
16
16
|
import { startWarmChild, type WarmChild } from './vercel-warm'
|
|
17
|
+
import { escapeRegex } from '../../utils/code'
|
|
17
18
|
import { listFiles, toPosixPath, writeText } from '../../utils/fs'
|
|
18
19
|
import { createVerboseLogger, type VerboseLogger } from '../../utils/verbose'
|
|
19
20
|
import type { BuildManifest, StaticFileMetadata } from '../../types'
|
|
@@ -149,7 +150,13 @@ function shipsFile(pack: PackRules, name: string) {
|
|
|
149
150
|
interface VercelConfig {
|
|
150
151
|
version: 3
|
|
151
152
|
routes?: (
|
|
152
|
-
| {
|
|
153
|
+
| {
|
|
154
|
+
src: string
|
|
155
|
+
dest?: string
|
|
156
|
+
methods?: string[]
|
|
157
|
+
headers?: Record<string, string>
|
|
158
|
+
continue?: boolean
|
|
159
|
+
}
|
|
153
160
|
| { handle: 'filesystem' }
|
|
154
161
|
)[]
|
|
155
162
|
overrides?: Record<string, { path?: string; contentType?: string }>
|
|
@@ -205,15 +212,12 @@ export async function writeVercelOutput(
|
|
|
205
212
|
// CDN serves those bytes itself - without it every stylesheet and chunk fell through to the
|
|
206
213
|
// server function: served, but at function cost with no edge cache.
|
|
207
214
|
...compatStaticRewrite(config, outputPath),
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
src: '^/assets/(chunks|fonts)/.*',
|
|
211
|
-
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
|
|
212
|
-
continue: true,
|
|
213
|
-
},
|
|
215
|
+
// Every content-hashed build asset, exactly the set `pnext start` serves immutable.
|
|
216
|
+
await immutableAssetRoute(),
|
|
214
217
|
// Proxy-matched paths go to the server function before the CDN filesystem
|
|
215
218
|
// check — `pnext start` runs the proxy ahead of static files too.
|
|
216
219
|
...(await proxyRoutes(config)),
|
|
220
|
+
...staticHeaderRoutes(staticFiles),
|
|
217
221
|
{ handle: 'filesystem' },
|
|
218
222
|
{ src: '^/.*$', dest: `/${SERVER_FUNCTION}` },
|
|
219
223
|
]
|
|
@@ -1375,9 +1379,83 @@ async function staticOverrides(
|
|
|
1375
1379
|
return overrides
|
|
1376
1380
|
}
|
|
1377
1381
|
|
|
1382
|
+
async function immutableAssetRoute() {
|
|
1383
|
+
const { immutableAssetPrefixes, immutableCacheControl } = await import('../serve/pipeline')
|
|
1384
|
+
const prefixes = [...new Set(immutableAssetPrefixes())].map(escapeRegex)
|
|
1385
|
+
return {
|
|
1386
|
+
src: `^/(?:${prefixes.join('|')})`,
|
|
1387
|
+
headers: { 'cache-control': immutableCacheControl },
|
|
1388
|
+
continue: true,
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Headers a CDN route cannot replay; a file that sets one stays on the server function.
|
|
1393
|
+
const functionOnlyHeaders = new Set([
|
|
1394
|
+
'content-length',
|
|
1395
|
+
'content-encoding',
|
|
1396
|
+
'transfer-encoding',
|
|
1397
|
+
'connection',
|
|
1398
|
+
'set-cookie',
|
|
1399
|
+
])
|
|
1400
|
+
|
|
1378
1401
|
function canServeStaticOnVercel(metadata: StaticFileMetadata) {
|
|
1402
|
+
if (metadata.status !== 200) return false
|
|
1403
|
+
const headers = routeHeaders(metadata)
|
|
1404
|
+
if (headers.length === 0) return true
|
|
1405
|
+
// ISR state needs the function to revalidate.
|
|
1379
1406
|
return (
|
|
1380
|
-
metadata.
|
|
1381
|
-
metadata.
|
|
1407
|
+
metadata.revalidateSeconds === undefined &&
|
|
1408
|
+
metadata.expireSeconds === undefined &&
|
|
1409
|
+
!metadata.tags?.length &&
|
|
1410
|
+
headers.every(([name]) => !functionOnlyHeaders.has(name.toLowerCase()))
|
|
1382
1411
|
)
|
|
1383
1412
|
}
|
|
1413
|
+
|
|
1414
|
+
/** Headers beyond content-type, which an override carries instead. */
|
|
1415
|
+
function routeHeaders(metadata: StaticFileMetadata) {
|
|
1416
|
+
return metadata.headers.filter(([name]) => name.toLowerCase() !== 'content-type')
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Keeps each route pattern well inside Vercel's route size limits.
|
|
1420
|
+
const maxRouteSourceLength = 2000
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Route headers for the CDN-served static files whose headers an override cannot carry, so the CDN
|
|
1424
|
+
* answers with what `pnext start` sends. Files sharing a header set share a route.
|
|
1425
|
+
*/
|
|
1426
|
+
function staticHeaderRoutes(staticFiles: Record<string, StaticFileMetadata>) {
|
|
1427
|
+
const groups = new Map<string, { headers: Record<string, string>; patterns: string[] }>()
|
|
1428
|
+
for (const [relative, metadata] of Object.entries(staticFiles)) {
|
|
1429
|
+
const extra = routeHeaders(metadata)
|
|
1430
|
+
if (extra.length === 0 || !canServeStaticOnVercel(metadata)) continue
|
|
1431
|
+
const headers = Object.fromEntries(new Headers(extra))
|
|
1432
|
+
const key = JSON.stringify(headers)
|
|
1433
|
+
const group = groups.get(key) ?? { headers, patterns: [] }
|
|
1434
|
+
groups.set(key, group)
|
|
1435
|
+
group.patterns.push(servedPathPattern(relative))
|
|
1436
|
+
}
|
|
1437
|
+
return [...groups.values()].flatMap(({ headers, patterns }) => {
|
|
1438
|
+
const sources: string[] = []
|
|
1439
|
+
for (const pattern of patterns) {
|
|
1440
|
+
const last = sources.length - 1
|
|
1441
|
+
if (last >= 0 && sources[last]!.length + pattern.length < maxRouteSourceLength) {
|
|
1442
|
+
sources[last] += `|${pattern}`
|
|
1443
|
+
} else {
|
|
1444
|
+
sources.push(pattern)
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return sources.map(source => ({
|
|
1448
|
+
src: `^/(?:${source})$`,
|
|
1449
|
+
methods: ['GET', 'HEAD'],
|
|
1450
|
+
headers,
|
|
1451
|
+
continue: true,
|
|
1452
|
+
}))
|
|
1453
|
+
})
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/** A static file's request paths: `a/index.html` also answers `/a` and `/a/`. */
|
|
1457
|
+
function servedPathPattern(relative: string) {
|
|
1458
|
+
if (relative === 'index.html') return '(?:index\\.html)?'
|
|
1459
|
+
if (!relative.endsWith('/index.html')) return escapeRegex(relative)
|
|
1460
|
+
return `${escapeRegex(relative.slice(0, -'/index.html'.length))}(?:/|/index\\.html)?`
|
|
1461
|
+
}
|
|
@@ -1352,10 +1352,18 @@ export const immutableCacheControl = 'public, max-age=31536000, immutable'
|
|
|
1352
1352
|
* and stay revalidating; the public/ tree the app ships is likewise untouched.
|
|
1353
1353
|
*/
|
|
1354
1354
|
export function immutableAssetPath(relativePath: string) {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1355
|
+
return immutableAssetPrefixes().some(prefix => relativePath.startsWith(prefix))
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** The public-relative prefixes `immutableAssetPath` covers. */
|
|
1359
|
+
export function immutableAssetPrefixes() {
|
|
1360
|
+
return [
|
|
1361
|
+
'assets/',
|
|
1362
|
+
'_next/static/',
|
|
1363
|
+
...getAssetExtensions()
|
|
1364
|
+
.staticAssetPublicPrefixes()
|
|
1365
|
+
.map(prefix => prefix.replace(/^\/+/, '')),
|
|
1366
|
+
]
|
|
1359
1367
|
}
|
|
1360
1368
|
|
|
1361
1369
|
async function firstFile(root: string, files: string[]) {
|
|
@@ -129,8 +129,10 @@ export function isRewriteDocument(html: string, pathname: string): boolean {
|
|
|
129
129
|
return !routePatternMatchesPathname(state.route, pathname, state.catchAllOptional)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export function routeParamBoundaryChanged(
|
|
133
|
-
|
|
132
|
+
export function routeParamBoundaryChanged(
|
|
133
|
+
doc: Document,
|
|
134
|
+
current = documentRouteState(document),
|
|
135
|
+
): boolean {
|
|
134
136
|
const incoming = documentRouteState(doc)
|
|
135
137
|
if (!current?.route || !incoming?.route) return false
|
|
136
138
|
if (current.route !== incoming.route) return true
|
|
@@ -1118,6 +1120,10 @@ type NavigationPaintHold = HTMLElement & {
|
|
|
1118
1120
|
__pnextObserver?: MutationObserver
|
|
1119
1121
|
/** The window scroll when the copy was taken. */
|
|
1120
1122
|
__pnextScroll?: [number, number]
|
|
1123
|
+
/** The `[data-scroll-root]` scroll the copy shows. */
|
|
1124
|
+
__pnextScrollTop?: number
|
|
1125
|
+
/** The navigation that removes the hold. */
|
|
1126
|
+
__pnextSequence?: number
|
|
1121
1127
|
}
|
|
1122
1128
|
|
|
1123
1129
|
function removeNavigationPaintHold(hold: NavigationPaintHold) {
|
|
@@ -1125,14 +1131,19 @@ function removeNavigationPaintHold(hold: NavigationPaintHold) {
|
|
|
1125
1131
|
hold.remove()
|
|
1126
1132
|
}
|
|
1127
1133
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1134
|
+
// A released hold until a frame renders without it: until then it is still what the screen shows.
|
|
1135
|
+
let releasedHold: NavigationPaintHold | null = null
|
|
1136
|
+
|
|
1137
|
+
function createNavigationPaintHold(sequence: number): NavigationPaintHold | null {
|
|
1138
|
+
// A prior hold on screen is taken over, so the older release is a no-op.
|
|
1139
|
+
const priorHold =
|
|
1140
|
+
document.querySelector<NavigationPaintHold>('[data-pnext-navigation-paint-hold]') ??
|
|
1141
|
+
releasedHold
|
|
1142
|
+
releasedHold = null
|
|
1143
|
+
if (priorHold) {
|
|
1144
|
+
priorHold.__pnextSequence = sequence
|
|
1145
|
+
return priorHold
|
|
1146
|
+
}
|
|
1136
1147
|
const roots = [...document.body.children].filter(
|
|
1137
1148
|
element =>
|
|
1138
1149
|
!element.hasAttribute('data-pnext-navigation-paint-hold') &&
|
|
@@ -1145,7 +1156,10 @@ function createNavigationPaintHold(): NavigationPaintHold | null {
|
|
|
1145
1156
|
hold.setAttribute('aria-hidden', 'true')
|
|
1146
1157
|
hold.style.cssText =
|
|
1147
1158
|
'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
|
|
1148
|
-
|
|
1159
|
+
// compat.next keeps 0.1.0's unscrolled copy, which its server action refresh scroll relies on.
|
|
1160
|
+
if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
|
|
1161
|
+
hold.__pnextScroll = [window.scrollX, window.scrollY]
|
|
1162
|
+
hold.__pnextSequence = sequence
|
|
1149
1163
|
// A <body> shell over the whole hold: body styles lay out and paint the copy as the page was.
|
|
1150
1164
|
const body = document.body.cloneNode() as HTMLElement
|
|
1151
1165
|
body.style.minHeight = '100%'
|
|
@@ -1181,15 +1195,13 @@ function attachNavigationPaintHold(
|
|
|
1181
1195
|
// Keep that live tree visible to focus management and selector observers;
|
|
1182
1196
|
// hiding <body> also hides every real destination element from browser gates.
|
|
1183
1197
|
placeNavigationPaintHold(hold)
|
|
1198
|
+
// A newer navigation leaves the hold up: its commit takes it over, or this release removes it.
|
|
1184
1199
|
hold.__pnextObserver ??= new MutationObserver(() => {
|
|
1185
|
-
if (sequence !== navigationSequence) {
|
|
1186
|
-
return removeNavigationPaintHold(hold)
|
|
1187
|
-
}
|
|
1188
1200
|
if (!hold.isConnected) placeNavigationPaintHold(hold)
|
|
1189
1201
|
})
|
|
1190
1202
|
hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
|
|
1191
1203
|
const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
|
|
1192
|
-
if (scrollRoot) scrollRoot.scrollTop = scrollTop
|
|
1204
|
+
if (scrollRoot) scrollRoot.scrollTop = hold.__pnextScrollTop ??= scrollTop
|
|
1193
1205
|
}
|
|
1194
1206
|
|
|
1195
1207
|
/** Append the hold scrolled as the departing page was; appending resets an element's scroll. */
|
|
@@ -1198,7 +1210,11 @@ function placeNavigationPaintHold(hold: NavigationPaintHold) {
|
|
|
1198
1210
|
if (hold.__pnextScroll) hold.scrollTo?.(...hold.__pnextScroll)
|
|
1199
1211
|
}
|
|
1200
1212
|
|
|
1201
|
-
async function releaseNavigationPaintHold(
|
|
1213
|
+
async function releaseNavigationPaintHold(
|
|
1214
|
+
hold: NavigationPaintHold | null,
|
|
1215
|
+
sequence: number,
|
|
1216
|
+
reveal?: () => void,
|
|
1217
|
+
) {
|
|
1202
1218
|
if (!hold) return
|
|
1203
1219
|
// mountRoute, client effects and the navigation commit have completed before
|
|
1204
1220
|
// this runs. Keep the departing frame through the next rendering opportunity,
|
|
@@ -1206,8 +1222,17 @@ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null, reve
|
|
|
1206
1222
|
if (window.requestAnimationFrame) {
|
|
1207
1223
|
await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
|
|
1208
1224
|
}
|
|
1225
|
+
if (hold.__pnextSequence !== sequence) return
|
|
1209
1226
|
reveal?.()
|
|
1210
1227
|
removeNavigationPaintHold(hold)
|
|
1228
|
+
if (!window.ResizeObserver) return
|
|
1229
|
+
releasedHold = hold
|
|
1230
|
+
// Observing fires at the next rendering update, the first frame that paints without the hold.
|
|
1231
|
+
const rendered = new ResizeObserver(() => {
|
|
1232
|
+
rendered.disconnect()
|
|
1233
|
+
if (releasedHold === hold) releasedHold = null
|
|
1234
|
+
})
|
|
1235
|
+
rendered.observe(document.documentElement)
|
|
1211
1236
|
}
|
|
1212
1237
|
|
|
1213
1238
|
/**
|
|
@@ -1581,10 +1606,13 @@ declare const __PNEXT_NEXT_ROUTER__: boolean | undefined
|
|
|
1581
1606
|
// returns to where its entry was left.
|
|
1582
1607
|
const coreNavigationScroll: NavigationScrollAction = (url, { pop, scroll }) => {
|
|
1583
1608
|
const state = historyState()
|
|
1584
|
-
const
|
|
1585
|
-
? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined)
|
|
1586
|
-
:
|
|
1587
|
-
const
|
|
1609
|
+
const saved = pop
|
|
1610
|
+
? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined))
|
|
1611
|
+
: undefined
|
|
1612
|
+
const [x = 0, y = 0] = saved ?? []
|
|
1613
|
+
// A traversal to an entry with no saved position lands on its hash, as a new visit does.
|
|
1614
|
+
const target =
|
|
1615
|
+
!saved && url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
|
|
1588
1616
|
if (target) target.scrollIntoView()
|
|
1589
1617
|
else if (pop || scroll !== false) window.scrollTo(x, y)
|
|
1590
1618
|
}
|
|
@@ -2317,6 +2345,8 @@ export function rearmVisiblePrefetches(): void {
|
|
|
2317
2345
|
const retry = () => {
|
|
2318
2346
|
if (generation !== revalidationPrefetchGeneration) return
|
|
2319
2347
|
revalidationPrefetchBlocked = false
|
|
2348
|
+
// A test host may tear the window down before the timer fires.
|
|
2349
|
+
if (typeof window === 'undefined') return
|
|
2320
2350
|
for (const element of prefetchedElements) {
|
|
2321
2351
|
if (!element.isConnected) {
|
|
2322
2352
|
prefetchedElements.delete(element)
|
|
@@ -2567,6 +2597,8 @@ export interface PrefetchEntry {
|
|
|
2567
2597
|
stateKey: string
|
|
2568
2598
|
/** A pending prefetch always dedupes; settled stale data must refetch. */
|
|
2569
2599
|
settled: boolean
|
|
2600
|
+
/** When the response landed, for entries that started as a pending prefetch. */
|
|
2601
|
+
settledTime?: number
|
|
2570
2602
|
/** Byte size of the settled document (LRU size accounting). */
|
|
2571
2603
|
bytes?: number
|
|
2572
2604
|
/** Settled as a shell-only (partial prefetch) response — see PrefetchedPage. */
|
|
@@ -2586,6 +2618,8 @@ export interface PrefetchEntry {
|
|
|
2586
2618
|
/** Slot-only key of the nav state the entry was rendered against. */
|
|
2587
2619
|
slotsKey: string
|
|
2588
2620
|
onInvalidate: (() => void)[]
|
|
2621
|
+
/** 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> }
|
|
2589
2623
|
}
|
|
2590
2624
|
|
|
2591
2625
|
const prefetchCache = new Map<string, PrefetchEntry>()
|
|
@@ -3015,10 +3049,20 @@ export function prefetchRoute(
|
|
|
3015
3049
|
options.element?.dispatchEvent(new CustomEvent('pnext:prefetchstart'))
|
|
3016
3050
|
const startEpoch = routerCacheEpoch()
|
|
3017
3051
|
const task = createPrefetchTask(cacheKey, options)
|
|
3052
|
+
// A core prefetch is the destination document itself, so its shell can paint an attached click.
|
|
3053
|
+
const shell: PrefetchEntry['shell'] =
|
|
3054
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' ? { listeners: new Set() } : undefined
|
|
3018
3055
|
const page = fetchPage(url.href, {
|
|
3019
3056
|
prefetch: full ? 'full' : 'auto',
|
|
3020
3057
|
navState: prefetchState,
|
|
3021
3058
|
task,
|
|
3059
|
+
onShell:
|
|
3060
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !full
|
|
3061
|
+
? html => {
|
|
3062
|
+
shell!.html = html
|
|
3063
|
+
shell!.listeners.forEach(listener => listener(html))
|
|
3064
|
+
}
|
|
3065
|
+
: undefined,
|
|
3022
3066
|
// Only when the shell is actually cached: the server strips it from the
|
|
3023
3067
|
// response, so without a local copy there would be nothing to merge into.
|
|
3024
3068
|
resumeFromShell: options.hoverResume === true && takeShellForUrl(url) !== null,
|
|
@@ -3107,6 +3151,7 @@ export function prefetchRoute(
|
|
|
3107
3151
|
slotsKey: slotsStateKey(prefetchState.slots ?? {}),
|
|
3108
3152
|
settled: false,
|
|
3109
3153
|
onInvalidate: options.onInvalidate ? [options.onInvalidate] : [],
|
|
3154
|
+
shell,
|
|
3110
3155
|
})
|
|
3111
3156
|
void page.then(fetched => {
|
|
3112
3157
|
const entry = prefetchCache.get(cacheKey)
|
|
@@ -3114,8 +3159,10 @@ export function prefetchRoute(
|
|
|
3114
3159
|
// Null = task stopped short with no data; a settled null would dedupe
|
|
3115
3160
|
// every later reveal into null, so drop the entry and let a viewport
|
|
3116
3161
|
// re-entry reschedule.
|
|
3117
|
-
if (fetched)
|
|
3118
|
-
|
|
3162
|
+
if (fetched) {
|
|
3163
|
+
entry.settled = true
|
|
3164
|
+
entry.settledTime = clientClockNow()
|
|
3165
|
+
} else prefetchCache.delete(cacheKey)
|
|
3119
3166
|
}
|
|
3120
3167
|
// Size accounting only applies to settled documents — re-trim now that
|
|
3121
3168
|
// this entry's bytes count against the budget.
|
|
@@ -3259,6 +3306,7 @@ function streamHasPendingHole(buffer: string) {
|
|
|
3259
3306
|
export async function readStreamedBody(
|
|
3260
3307
|
response: Response,
|
|
3261
3308
|
onShell: (shellHtml: string) => void,
|
|
3309
|
+
ready?: (shellHtml: string) => boolean,
|
|
3262
3310
|
): Promise<string> {
|
|
3263
3311
|
const reader = response.body?.getReader()
|
|
3264
3312
|
if (!reader) return response.text()
|
|
@@ -3279,7 +3327,7 @@ export async function readStreamedBody(
|
|
|
3279
3327
|
// the caller swap the complete, materialized document.
|
|
3280
3328
|
!/<\/html>\s*$/.test(shellBuffer)
|
|
3281
3329
|
) {
|
|
3282
|
-
if (streamHasPendingHole(shellBuffer)) {
|
|
3330
|
+
if (streamHasPendingHole(shellBuffer) && (!ready || ready(shellBuffer))) {
|
|
3283
3331
|
shellDelivered = true
|
|
3284
3332
|
// Expose only holes still pending at this commit. Continuations already present in the
|
|
3285
3333
|
// read are included so showLoadingShell can materialize them before choosing a fallback.
|
|
@@ -3406,7 +3454,7 @@ async function fetchPage(
|
|
|
3406
3454
|
// Only the streamed-navigation read path (readStreamedBody) can consume a
|
|
3407
3455
|
// late metadata tail written after `</html>`, so only it asks for one: the
|
|
3408
3456
|
// server keeps blocking on `generateMetadata` for every other consumer.
|
|
3409
|
-
if (options.onShell) headers[LATE_METADATA_HEADER] = '1'
|
|
3457
|
+
if (options.onShell && !options.prefetch) headers[LATE_METADATA_HEADER] = '1'
|
|
3410
3458
|
if (options.fullRender) headers['x-pnext-full-render'] = '1'
|
|
3411
3459
|
if (options.resumeFromShell) headers['x-pnext-resume-shell'] = '1'
|
|
3412
3460
|
|
|
@@ -3675,7 +3723,16 @@ async function fetchPage(
|
|
|
3675
3723
|
segmentPayload && shellOnly && !response.headers.has(SEGMENT_ROUTE_HEADER)
|
|
3676
3724
|
const responseBody =
|
|
3677
3725
|
strippedBody ??
|
|
3678
|
-
(options.onShell
|
|
3726
|
+
(options.onShell
|
|
3727
|
+
? await readStreamedBody(
|
|
3728
|
+
response,
|
|
3729
|
+
options.onShell,
|
|
3730
|
+
// A prefetch read can end mid-shell; its shell counts once the document's tail scripts land.
|
|
3731
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && options.prefetch
|
|
3732
|
+
? shell => shell.includes('id="__PNEXT_NAV_STATE__"') && /<\/script>\s*$/.test(shell)
|
|
3733
|
+
: undefined,
|
|
3734
|
+
)
|
|
3735
|
+
: await response.text())
|
|
3679
3736
|
let html = segmentPayload ? segmentDocumentHtml(responseBody, bodySegment) : responseBody
|
|
3680
3737
|
if (html === null) return null
|
|
3681
3738
|
// A LAYOUT-only prefetch carries no page: compose this URL's cached page frame
|
|
@@ -4083,6 +4140,10 @@ export function showLoadingShell(
|
|
|
4083
4140
|
if (!markerRange && slotContainer === document.body && !scopedOwner) return false
|
|
4084
4141
|
const container = scopedOwner ?? slotContainer
|
|
4085
4142
|
const fragment = document.createDocumentFragment()
|
|
4143
|
+
// A live page slot is filled from the incoming page slot, never from a container inside it.
|
|
4144
|
+
// compat.next keeps its own fill, which its nested parallel-slot layouts rely on.
|
|
4145
|
+
const incomingSlot =
|
|
4146
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && markerRange ? pageSlotRange(doc.body) : null
|
|
4086
4147
|
// The streamed document can already contain an ancestor layout that resolved before the
|
|
4087
4148
|
// loading boundary. Keep that prefix when painting the fallback: replacing the target
|
|
4088
4149
|
// with only the suspense children loses the eagerly prefetched layout.
|
|
@@ -4091,17 +4152,19 @@ export function showLoadingShell(
|
|
|
4091
4152
|
(container.id ? doc.getElementById(container.id) : null) ??
|
|
4092
4153
|
doc.querySelector('[data-pnext-root]') ??
|
|
4093
4154
|
(container === document.body ? doc.body : null)
|
|
4094
|
-
const sourceNodes =
|
|
4095
|
-
? [
|
|
4096
|
-
:
|
|
4097
|
-
?
|
|
4098
|
-
|
|
4099
|
-
inline
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
:
|
|
4103
|
-
|
|
4104
|
-
|
|
4155
|
+
const sourceNodes = incomingSlot
|
|
4156
|
+
? incomingSlot[2]
|
|
4157
|
+
: incomingTarget
|
|
4158
|
+
? [...incomingTarget.childNodes]
|
|
4159
|
+
: inline
|
|
4160
|
+
? // The inline anchor is whatever element holds the markers (often <body>);
|
|
4161
|
+
// only the fallback BETWEEN them is this boundary's content.
|
|
4162
|
+
inline.nodes
|
|
4163
|
+
: suspense
|
|
4164
|
+
? [...(suspense.closest('pnext-layout[data-pnext-segment]') ?? suspense).childNodes]
|
|
4165
|
+
: // Boundary-free markup with no page container to copy from: there is
|
|
4166
|
+
// nothing this paint could put on screen.
|
|
4167
|
+
null
|
|
4105
4168
|
if (!sourceNodes) return false
|
|
4106
4169
|
// A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
|
|
4107
4170
|
// bar the instant this returns true), so the window route state must reflect the
|
|
@@ -4470,14 +4533,41 @@ function unsettledPrefetchDeadline(): Promise<null> {
|
|
|
4470
4533
|
return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
|
|
4471
4534
|
}
|
|
4472
4535
|
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4536
|
+
/**
|
|
4537
|
+
* The in-flight prefetch's document, or `null` once it has waited long enough. A streamed shell
|
|
4538
|
+
* that paints this navigation first commits it to that prefetch, so it waits for the body.
|
|
4539
|
+
*/
|
|
4540
|
+
function attachToPrefetch(
|
|
4541
|
+
entry: PrefetchEntry,
|
|
4542
|
+
onShell?: (shellHtml: string) => boolean,
|
|
4543
|
+
): Promise<PrefetchedPage | null> {
|
|
4544
|
+
return new Promise(resolve => {
|
|
4545
|
+
const shell = entry.shell
|
|
4546
|
+
const done = (page: PrefetchedPage | Promise<PrefetchedPage | null> | null) => {
|
|
4547
|
+
clearTimeout(timer)
|
|
4548
|
+
shell?.listeners.delete(paint)
|
|
4549
|
+
resolve(page)
|
|
4550
|
+
}
|
|
4551
|
+
const paint = (html: string) => onShell?.(html) && done(entry.page)
|
|
4552
|
+
const timer = setTimeout(done, UNSETTLED_PREFETCH_WAIT_MS, null)
|
|
4553
|
+
void entry.page.then(done)
|
|
4554
|
+
if (shell?.html) paint(shell.html)
|
|
4555
|
+
else shell?.listeners.add(paint)
|
|
4556
|
+
})
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
// Core reuses a kept copy of a dynamic page this long; past it only the copy's frame paints.
|
|
4560
|
+
const KEPT_DATA_MS = 5_000
|
|
4561
|
+
|
|
4562
|
+
function keptDataFresh(entry: PrefetchEntry, now: number): boolean {
|
|
4563
|
+
return !entry.settled || now - (entry.settledTime ?? entry.time) < KEPT_DATA_MS
|
|
4564
|
+
}
|
|
4476
4565
|
|
|
4477
4566
|
async function pageForNavigation(
|
|
4478
4567
|
url: URL,
|
|
4479
4568
|
options: SoftNavigateOptions = {},
|
|
4480
|
-
|
|
4569
|
+
/** Paints a streamed shell; true when it committed the navigation. */
|
|
4570
|
+
onShell?: (shellHtml: string) => boolean,
|
|
4481
4571
|
/**
|
|
4482
4572
|
* Paint a cached STATIC STAGE into the live page before the dynamic-stage request goes
|
|
4483
4573
|
* out. Unlike `onShell` this markup is not a loading fallback but the route's real static
|
|
@@ -4514,11 +4604,17 @@ async function pageForNavigation(
|
|
|
4514
4604
|
pageFrame.eligible = false
|
|
4515
4605
|
onStaticStage?.(html, postponedShell)
|
|
4516
4606
|
}
|
|
4517
|
-
const
|
|
4607
|
+
const usable = prefetchEntriesForNavigation(key).filter(
|
|
4518
4608
|
entry =>
|
|
4519
4609
|
entryMatchesNavState(entry, departureNavState) &&
|
|
4520
4610
|
(!entry.settled || now - entry.time < entry.staleTimeMs),
|
|
4521
4611
|
)
|
|
4612
|
+
// Core prefers a kept copy whose data is still fresh.
|
|
4613
|
+
const cached =
|
|
4614
|
+
(typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
|
|
4615
|
+
!prefetchStaleTimePolicy &&
|
|
4616
|
+
usable.find(entry => keptDataFresh(entry, now))) ||
|
|
4617
|
+
usable[0]
|
|
4522
4618
|
// Popstate restores a history entry's own parallel-route state; a prefetched response was
|
|
4523
4619
|
// rendered against the pre-navigation state and may not match, so back/forward always
|
|
4524
4620
|
// fetches. A refresh bypasses the cache entirely.
|
|
@@ -4539,15 +4635,21 @@ async function pageForNavigation(
|
|
|
4539
4635
|
const page =
|
|
4540
4636
|
cached.settled || cached.full
|
|
4541
4637
|
? await cached.page
|
|
4542
|
-
:
|
|
4638
|
+
: typeof __PNEXT_NEXT_ROUTER__ === 'undefined'
|
|
4639
|
+
? await attachToPrefetch(cached, onShell)
|
|
4640
|
+
: await Promise.race([cached.page, unsettledPrefetchDeadline()])
|
|
4543
4641
|
if (page && !page.shellOnly) {
|
|
4544
4642
|
if (
|
|
4545
4643
|
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
|
|
4546
4644
|
!prefetchStaleTimePolicy &&
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4645
|
+
!keptDataFresh(cached, now) &&
|
|
4646
|
+
!documentStaticHintFromHtml(page.html)?.isStatic
|
|
4647
|
+
) {
|
|
4648
|
+
// Next's way past the data window: paint the frame (loading fallbacks included), fetch the data.
|
|
4649
|
+
const frame = sliceShell(page.html)
|
|
4650
|
+
// The response shell is this frame's twin; painting it again would swap the same layout.
|
|
4651
|
+
if (frame !== null && onShell?.(frame)) onShell = () => false
|
|
4652
|
+
} else return page
|
|
4551
4653
|
}
|
|
4552
4654
|
// Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
|
|
4553
4655
|
// the navigation issued no duplicate fetch, so paint the static stage it landed and let
|
|
@@ -5110,17 +5212,23 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5110
5212
|
// server-side, and re-painting would replace a committed loading state with a second,
|
|
5111
5213
|
// different one. A segment's loading fallback commits ONCE per navigation.
|
|
5112
5214
|
let cachedStagePainted = false
|
|
5215
|
+
// The route whose islands a stage paint mounted; the commit keeps them instead of remounting.
|
|
5216
|
+
let paintedRoute: BrowserRouteState | undefined
|
|
5217
|
+
// A painted loading boundary or stage is a committed navigation state.
|
|
5218
|
+
const commitPaint = () => {
|
|
5219
|
+
paintedRoute = window.__PNEXT_ROUTE__
|
|
5220
|
+
pushOptimisticUrl()
|
|
5221
|
+
scheduleNavigationScroll(url, options)
|
|
5222
|
+
}
|
|
5113
5223
|
const devSoftNavigation = nextDevDocument()
|
|
5114
5224
|
const onShell =
|
|
5115
5225
|
restorePage || options.pop || refreshLike
|
|
5116
5226
|
? undefined
|
|
5117
5227
|
: (shellHtml: string) => {
|
|
5118
|
-
if (cachedStagePainted) return
|
|
5119
|
-
if (
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
pushOptimisticUrl()
|
|
5123
|
-
scheduleNavigationScroll(url, options)
|
|
5228
|
+
if (cachedStagePainted || devSoftNavigation) return false
|
|
5229
|
+
if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return false
|
|
5230
|
+
commitPaint()
|
|
5231
|
+
return true
|
|
5124
5232
|
}
|
|
5125
5233
|
// The per-segment cache's answer for this navigation, looked up ONCE: it decides both
|
|
5126
5234
|
// whether the generic loading shell should paint (a real cached static segment is strictly
|
|
@@ -5140,8 +5248,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5140
5248
|
if (devSoftNavigation) return
|
|
5141
5249
|
if (!commitStaticStage(html, sequence, url, postponedShell)) return
|
|
5142
5250
|
cachedStagePainted = true
|
|
5143
|
-
|
|
5144
|
-
scheduleNavigationScroll(url, options)
|
|
5251
|
+
commitPaint()
|
|
5145
5252
|
}
|
|
5146
5253
|
: undefined
|
|
5147
5254
|
// Loading-shell reuse: when the navigation must fetch, paint the CACHED shell for the
|
|
@@ -5174,8 +5281,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5174
5281
|
: false
|
|
5175
5282
|
if (shellPainted) {
|
|
5176
5283
|
cachedStagePainted = true
|
|
5177
|
-
|
|
5178
|
-
scheduleNavigationScroll(url, options)
|
|
5284
|
+
commitPaint()
|
|
5179
5285
|
}
|
|
5180
5286
|
}
|
|
5181
5287
|
// Which navigations may fetch the PAGE frame alone. Never on popstate (a history entry
|
|
@@ -5348,7 +5454,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5348
5454
|
// would compare the destination with itself and hide every param transition.
|
|
5349
5455
|
const parallelSlotsChanged = navSlotsChanged(doc)
|
|
5350
5456
|
const remountPageIslands =
|
|
5351
|
-
(options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc)
|
|
5457
|
+
(options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc, paintedRoute)
|
|
5352
5458
|
const committedRoute = documentRouteState(doc)
|
|
5353
5459
|
if (committedRoute) window.__PNEXT_ROUTE__ = committedRoute
|
|
5354
5460
|
|
|
@@ -5449,13 +5555,13 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5449
5555
|
// The resolved client-root commit shows this copy only while its first complete paint settles.
|
|
5450
5556
|
const paintHoldScrollTop =
|
|
5451
5557
|
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.
|
|
5455
|
-
window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
|
|
5558
|
+
const paintHold = createNavigationPaintHold(sequence)
|
|
5456
5559
|
let focusedBeforeSwap: Element | null = null
|
|
5457
5560
|
let navigationFocusTarget: HTMLElement | null = null
|
|
5458
5561
|
try {
|
|
5562
|
+
// Wake route-keyed layout children while the departing DOM is still attached. Their layout
|
|
5563
|
+
// cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
|
|
5564
|
+
window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
|
|
5459
5565
|
if (unmountSlotlessPage && departingRouteKey && departingRouteKey !== targetRouteKey) {
|
|
5460
5566
|
// Cover the lifecycle-only empty-page pass. This is the same outgoing screen clone used for
|
|
5461
5567
|
// the final atomic commit, attached early enough that no empty intermediate frame can paint.
|
|
@@ -5569,7 +5675,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5569
5675
|
}
|
|
5570
5676
|
}
|
|
5571
5677
|
} finally {
|
|
5572
|
-
await releaseNavigationPaintHold(paintHold, () => {
|
|
5678
|
+
await releaseNavigationPaintHold(paintHold, sequence, () => {
|
|
5573
5679
|
if (!url.hash && !stylesheetReconciler && sequence === navigationSequence)
|
|
5574
5680
|
pruneStylesheets(doc)
|
|
5575
5681
|
})
|
|
@@ -5605,23 +5711,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5605
5711
|
if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
|
|
5606
5712
|
restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
|
|
5607
5713
|
}
|
|
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
|
-
}
|
|
5615
|
-
|
|
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
|
-
})
|
|
5625
5714
|
}
|
|
5626
5715
|
|
|
5627
5716
|
function isBotUserAgent() {
|
|
@@ -222,6 +222,8 @@ function installStreamErrorBoundaries(
|
|
|
222
222
|
const observe = () => {
|
|
223
223
|
scan(document)
|
|
224
224
|
new MutationObserver(mutations => {
|
|
225
|
+
// A test host may tear the DOM globals down before a queued record is delivered.
|
|
226
|
+
if (typeof Element === 'undefined') return
|
|
225
227
|
for (const mutation of mutations) {
|
|
226
228
|
for (const node of Array.from(mutation.addedNodes)) {
|
|
227
229
|
if (!(node instanceof Element)) continue
|
package/src/runtime/modules.ts
CHANGED
|
@@ -1440,6 +1440,7 @@ async function writeCompiledFile(file: string, contents: string) {
|
|
|
1440
1440
|
function missingCompiledArtifact(entry: string): string | undefined {
|
|
1441
1441
|
const root = compiledArtifactProfileRoot(entry)
|
|
1442
1442
|
if (!root) return existsSync(entry) ? undefined : entry
|
|
1443
|
+
const serverRoot = path.dirname(root)
|
|
1443
1444
|
|
|
1444
1445
|
const visiting = new Set<string>()
|
|
1445
1446
|
const visit = (file: string): string | undefined => {
|
|
@@ -1447,7 +1448,7 @@ function missingCompiledArtifact(entry: string): string | undefined {
|
|
|
1447
1448
|
if (!existsSync(file)) return file
|
|
1448
1449
|
// Raw framework/package file URLs are ordinary immutable dependencies, not
|
|
1449
1450
|
// members of the materialized graph whose publication order we own.
|
|
1450
|
-
if (!isInside(
|
|
1451
|
+
if (!isInside(serverRoot, file) || !compiledScriptFilePattern.test(file)) return undefined
|
|
1451
1452
|
|
|
1452
1453
|
visiting.add(file)
|
|
1453
1454
|
const cachedTargets = completeArtifactClosures.get(file)
|
|
@@ -1480,13 +1481,13 @@ function missingCompiledArtifact(entry: string): string | undefined {
|
|
|
1480
1481
|
} catch {
|
|
1481
1482
|
return sourcePath
|
|
1482
1483
|
}
|
|
1483
|
-
|
|
1484
|
+
const emittedRoot = compiledArtifactProfileRoot(target)
|
|
1485
|
+
if (!emittedRoot) continue
|
|
1486
|
+
// Profiles share one closure: a 'use client' module compiles its imports into the client profile.
|
|
1487
|
+
if (!isInside(serverRoot, target)) {
|
|
1484
1488
|
if (existsSync(target)) continue
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
// dead reference to this same profile can be a relocated cache member.
|
|
1488
|
-
if (!emittedRoot || path.basename(emittedRoot) !== path.basename(root)) continue
|
|
1489
|
-
target = path.resolve(root, path.relative(emittedRoot, target))
|
|
1489
|
+
// A dead reference into another cache is a relocated member of this one.
|
|
1490
|
+
target = path.resolve(serverRoot, path.relative(path.dirname(emittedRoot), target))
|
|
1490
1491
|
}
|
|
1491
1492
|
const missing = visit(target)
|
|
1492
1493
|
if (missing) return missing
|