@wular/pnext 0.1.0 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,11 +45,11 @@ Prefetch warms the target page and its assets. Set the mode per link with the `p
45
45
 
46
46
  The app-wide default can be set with the `prefetch` field in `pnext.config.ts`, described in [Config](./config.md).
47
47
 
48
- Requests use low network priority. At most four run at once, though the hover-intent lane allows up to twelve. The core fallback expiry is five minutes. Prefetch does nothing in development.
48
+ Requests use low network priority. At most four run at once, though the hover-intent lane allows up to twelve. The core fallback expiry is five minutes. Development prefetches like production, except under `compat.next`, which follows Next and does not prefetch in development.
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. Back and forward stay soft and restore scroll. Cross-origin targets, non-HTML responses, and fetch failures fall back to a full page load.
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. When a link shows a dynamic page again from the router cache, the page refreshes in the background and the fresh render replaces it in place. 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
- | { src: string; dest?: string; headers?: Record<string, string>; continue?: boolean }
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
- // Chunk and font filenames are content-hashed; serve them immutable.
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.status === 200 &&
1381
- metadata.headers.every(([name]) => name.toLowerCase() === 'content-type')
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
- if (relativePath.startsWith('assets/') || relativePath.startsWith('_next/static/')) return true
1356
- return getAssetExtensions()
1357
- .staticAssetPublicPrefixes()
1358
- .some(prefix => relativePath.startsWith(prefix.replace(/^\/+/, '')))
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[]) {
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  exportDocumentFetcher,
22
22
  loadingShellPredictionPolicy,
23
+ prefetchStaleTimePolicy,
23
24
  prefetchStaleTimeMs,
24
25
  revalidationPrefetchDelayMs,
25
26
  segmentCachePolicy,
@@ -30,7 +31,9 @@ import {
30
31
  emitLocationChange,
31
32
  emitNavigationCommit,
32
33
  emitNavigationStart,
34
+ navigationScrollAction,
33
35
  scheduleNavigationScroll,
36
+ setNavigationScrollAction,
34
37
  withSilentLocationChange,
35
38
  } from './events'
36
39
  import type {
@@ -39,6 +42,7 @@ import type {
39
42
  EntryModule,
40
43
  LinkPrefetchMode,
41
44
  LoadingShellPrediction,
45
+ NavigationScrollAction,
42
46
  PrefetchedPage,
43
47
  PrefetchOptions,
44
48
  SegmentCacheHit,
@@ -57,14 +61,17 @@ export interface BrowserRouteState {
57
61
  runtimePrefetch?: boolean
58
62
  }
59
63
 
64
+ const ROUTE_STATE_PREFIX = 'window.__PNEXT_ROUTE__='
65
+
60
66
  function documentRouteState(doc: Document): BrowserRouteState | undefined {
61
- const prefix = 'window.__PNEXT_ROUTE__='
62
67
  const source = [...doc.scripts]
63
68
  .map(script => script.textContent ?? '')
64
- .find(text => text.startsWith(prefix))
69
+ .find(text => text.startsWith(ROUTE_STATE_PREFIX))
65
70
  if (!source) return undefined
66
71
  try {
67
- return JSON.parse(source.slice(prefix.length).replace(/;\s*$/, '')) as BrowserRouteState
72
+ return JSON.parse(
73
+ source.slice(ROUTE_STATE_PREFIX.length).replace(/;\s*$/, ''),
74
+ ) as BrowserRouteState
68
75
  } catch {
69
76
  return undefined
70
77
  }
@@ -320,6 +327,8 @@ function materializeStreamedSegments(doc: Document) {
320
327
  for (const chunk of doc.querySelectorAll<HTMLElement>(
321
328
  'div[hidden][data-pnext-stream], template[data-pnext-stream]',
322
329
  )) {
330
+ // Its promotion script has nothing left to do, and re-running it would only trip a script CSP.
331
+ if (chunk.nextElementSibling?.tagName === 'SCRIPT') chunk.nextElementSibling.remove()
323
332
  const id = chunk.getAttribute('data-pnext-stream')
324
333
  const suspense = id
325
334
  ? doc.querySelector(`pnext-suspense[data-pnext-suspense="${CSS.escape(id)}"]`)
@@ -1053,6 +1062,11 @@ function swapBody(
1053
1062
  continue
1054
1063
  }
1055
1064
  if (node.hasAttribute('data-pnext-dev')) continue
1065
+ // Callers install the route state from the document; re-running it only trips a script CSP.
1066
+ if (node.text.startsWith(ROUTE_STATE_PREFIX)) {
1067
+ fragment.append(document.importNode(node, true))
1068
+ continue
1069
+ }
1056
1070
  // Parser-created scripts are inert; rebuild them so props, route
1057
1071
  // state, and streamed-chunk patches execute in document order when the
1058
1072
  // fragment lands in the live document.
@@ -1092,7 +1106,7 @@ function swapBody(
1092
1106
  cursor = next
1093
1107
  }
1094
1108
  }
1095
- if (paintHold) document.body.append(paintHold)
1109
+ if (paintHold) placeNavigationPaintHold(paintHold)
1096
1110
  // Track the entry of what is now on screen (dropped when the incoming route
1097
1111
  // has none, so a stale entry is never attributed to it).
1098
1112
  if (entrySrc) document.documentElement.setAttribute(ENTRY_SCRIPT_ATTRIBUTE, entrySrc)
@@ -1102,6 +1116,12 @@ function swapBody(
1102
1116
  /** Keep the last complete screen painted while client roots mount into the committed document. */
1103
1117
  type NavigationPaintHold = HTMLElement & {
1104
1118
  __pnextObserver?: MutationObserver
1119
+ /** The window scroll when the copy was taken. */
1120
+ __pnextScroll?: [number, number]
1121
+ /** The `[data-scroll-root]` scroll the copy shows. */
1122
+ __pnextScrollTop?: number
1123
+ /** The navigation that removes the hold. */
1124
+ __pnextSequence?: number
1105
1125
  }
1106
1126
 
1107
1127
  function removeNavigationPaintHold(hold: NavigationPaintHold) {
@@ -1109,14 +1129,19 @@ function removeNavigationPaintHold(hold: NavigationPaintHold) {
1109
1129
  hold.remove()
1110
1130
  }
1111
1131
 
1112
- function createNavigationPaintHold(): NavigationPaintHold | null {
1113
- // A newer navigation may start before the prior hold's settling frame. Retire
1114
- // that transaction synchronously so holds never nest (the older async release
1115
- // becomes a no-op).
1116
- const priorHold = document.querySelector<NavigationPaintHold>(
1117
- '[data-pnext-navigation-paint-hold]',
1118
- )
1119
- if (priorHold) removeNavigationPaintHold(priorHold)
1132
+ // A released hold until a frame renders without it: until then it is still what the screen shows.
1133
+ let releasedHold: NavigationPaintHold | null = null
1134
+
1135
+ function createNavigationPaintHold(sequence: number): NavigationPaintHold | null {
1136
+ // A prior hold on screen is taken over, so the older release is a no-op.
1137
+ const priorHold =
1138
+ document.querySelector<NavigationPaintHold>('[data-pnext-navigation-paint-hold]') ??
1139
+ releasedHold
1140
+ releasedHold = null
1141
+ if (priorHold) {
1142
+ priorHold.__pnextSequence = sequence
1143
+ return priorHold
1144
+ }
1120
1145
  const roots = [...document.body.children].filter(
1121
1146
  element =>
1122
1147
  !element.hasAttribute('data-pnext-navigation-paint-hold') &&
@@ -1124,12 +1149,20 @@ function createNavigationPaintHold(): NavigationPaintHold | null {
1124
1149
  element.textContent?.trim(),
1125
1150
  )
1126
1151
  if (roots.length === 0) return null
1127
- const hold = document.createElement('div')
1152
+ const hold: NavigationPaintHold = document.createElement('div')
1128
1153
  hold.setAttribute('data-pnext-navigation-paint-hold', '')
1129
1154
  hold.setAttribute('aria-hidden', 'true')
1130
1155
  hold.style.cssText =
1131
1156
  'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
1132
- hold.append(...roots.map(root => root.cloneNode(true)))
1157
+ // compat.next keeps 0.1.0's unscrolled copy, which its server action refresh scroll relies on.
1158
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
1159
+ hold.__pnextScroll = [window.scrollX, window.scrollY]
1160
+ hold.__pnextSequence = sequence
1161
+ // A <body> shell over the whole hold: body styles lay out and paint the copy as the page was.
1162
+ const body = document.body.cloneNode() as HTMLElement
1163
+ body.style.minHeight = '100%'
1164
+ body.append(...roots.map(root => root.cloneNode(true)))
1165
+ hold.append(body)
1133
1166
  // The copy is paint-only: route mount scans and id lookups must never treat it as live UI.
1134
1167
  for (const node of hold.querySelectorAll('[data-pnext-client]'))
1135
1168
  node.removeAttribute('data-pnext-client')
@@ -1159,19 +1192,27 @@ function attachNavigationPaintHold(
1159
1192
  // The opaque fixed clone covers the viewport while the committed tree mounts.
1160
1193
  // Keep that live tree visible to focus management and selector observers;
1161
1194
  // hiding <body> also hides every real destination element from browser gates.
1162
- document.body.append(hold)
1195
+ placeNavigationPaintHold(hold)
1196
+ // A newer navigation leaves the hold up: its commit takes it over, or this release removes it.
1163
1197
  hold.__pnextObserver ??= new MutationObserver(() => {
1164
- if (sequence !== navigationSequence) {
1165
- return removeNavigationPaintHold(hold)
1166
- }
1167
- if (!hold.isConnected) document.body.append(hold)
1198
+ if (!hold.isConnected) placeNavigationPaintHold(hold)
1168
1199
  })
1169
1200
  hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
1170
1201
  const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
1171
- if (scrollRoot) scrollRoot.scrollTop = scrollTop
1202
+ if (scrollRoot) scrollRoot.scrollTop = hold.__pnextScrollTop ??= scrollTop
1203
+ }
1204
+
1205
+ /** Append the hold scrolled as the departing page was; appending resets an element's scroll. */
1206
+ function placeNavigationPaintHold(hold: NavigationPaintHold) {
1207
+ document.body.append(hold)
1208
+ if (hold.__pnextScroll) hold.scrollTo?.(...hold.__pnextScroll)
1172
1209
  }
1173
1210
 
1174
- async function releaseNavigationPaintHold(hold: NavigationPaintHold | null) {
1211
+ async function releaseNavigationPaintHold(
1212
+ hold: NavigationPaintHold | null,
1213
+ sequence: number,
1214
+ reveal?: () => void,
1215
+ ) {
1175
1216
  if (!hold) return
1176
1217
  // mountRoute, client effects and the navigation commit have completed before
1177
1218
  // this runs. Keep the departing frame through the next rendering opportunity,
@@ -1179,7 +1220,17 @@ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null) {
1179
1220
  if (window.requestAnimationFrame) {
1180
1221
  await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
1181
1222
  }
1223
+ if (hold.__pnextSequence !== sequence) return
1224
+ reveal?.()
1182
1225
  removeNavigationPaintHold(hold)
1226
+ if (!window.ResizeObserver) return
1227
+ releasedHold = hold
1228
+ // Observing fires at the next rendering update, the first frame that paints without the hold.
1229
+ const rendered = new ResizeObserver(() => {
1230
+ rendered.disconnect()
1231
+ if (releasedHold === hold) releasedHold = null
1232
+ })
1233
+ rendered.observe(document.documentElement)
1183
1234
  }
1184
1235
 
1185
1236
  /**
@@ -1510,6 +1561,11 @@ function isDevDocument() {
1510
1561
  return Boolean(document.querySelector('script[data-pnext-dev]'))
1511
1562
  }
1512
1563
 
1564
+ // Next's dev router neither prefetches nor paints loading states; core dev navigates like production.
1565
+ function nextDevDocument() {
1566
+ return prefetchStaleTimePolicy !== undefined && isDevDocument()
1567
+ }
1568
+
1513
1569
  // `replace` is also set for history TRAVERSALS (options.pop): assign() would
1514
1570
  // push a new entry over the popped one and truncate the forward stack, so a
1515
1571
  // bailout during back/forward must replace the current entry instead.
@@ -1541,6 +1597,27 @@ function saveScrollPosition() {
1541
1597
  }
1542
1598
  }
1543
1599
 
1600
+ // Defined by compat.next builds, whose router policies replace core's scroll and refresh ones.
1601
+ declare const __PNEXT_NEXT_ROUTER__: boolean | undefined
1602
+
1603
+ // Core's scroll policy when compat installs none: a new page starts at the top, a traversal
1604
+ // returns to where its entry was left.
1605
+ const coreNavigationScroll: NavigationScrollAction = (url, { pop, scroll }) => {
1606
+ const state = historyState()
1607
+ const saved = pop
1608
+ ? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined))
1609
+ : undefined
1610
+ const [x = 0, y = 0] = saved ?? []
1611
+ // A traversal to an entry with no saved position lands on its hash, as a new visit does.
1612
+ const target =
1613
+ !saved && url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
1614
+ if (target) target.scrollIntoView()
1615
+ else if (pop || scroll !== false) window.scrollTo(x, y)
1616
+ }
1617
+
1618
+ // Where each entry was left, pops included, which cannot write the state of the entry they leave.
1619
+ const entryScroll = new Map<unknown, number[]>()
1620
+
1544
1621
  function storeNavState() {
1545
1622
  try {
1546
1623
  history.replaceState(
@@ -2266,6 +2343,8 @@ export function rearmVisiblePrefetches(): void {
2266
2343
  const retry = () => {
2267
2344
  if (generation !== revalidationPrefetchGeneration) return
2268
2345
  revalidationPrefetchBlocked = false
2346
+ // A test host may tear the window down before the timer fires.
2347
+ if (typeof window === 'undefined') return
2269
2348
  for (const element of prefetchedElements) {
2270
2349
  if (!element.isConnected) {
2271
2350
  prefetchedElements.delete(element)
@@ -2516,6 +2595,8 @@ export interface PrefetchEntry {
2516
2595
  stateKey: string
2517
2596
  /** A pending prefetch always dedupes; settled stale data must refetch. */
2518
2597
  settled: boolean
2598
+ /** When the response landed, for entries that started as a pending prefetch. */
2599
+ settledTime?: number
2519
2600
  /** Byte size of the settled document (LRU size accounting). */
2520
2601
  bytes?: number
2521
2602
  /** Settled as a shell-only (partial prefetch) response — see PrefetchedPage. */
@@ -2855,9 +2936,7 @@ export function prefetchRoute(
2855
2936
  href: string,
2856
2937
  options: PrefetchOptions = {},
2857
2938
  ): Promise<PrefetchedPage | null> {
2858
- // Dev pages render per request and entries build on demand; hover prefetch
2859
- // would hammer the dev server for little gain. Navigation still fetches.
2860
- if (isDevDocument()) return Promise.resolve(null)
2939
+ if (nextDevDocument()) return Promise.resolve(null)
2861
2940
  if (isBotUserAgent()) return Promise.resolve(null)
2862
2941
  // `strict` is handled at the facade, the only entry point that can be handed
2863
2942
  // an unparseable href; everything reaching here already resolved once.
@@ -3065,8 +3144,10 @@ export function prefetchRoute(
3065
3144
  // Null = task stopped short with no data; a settled null would dedupe
3066
3145
  // every later reveal into null, so drop the entry and let a viewport
3067
3146
  // re-entry reschedule.
3068
- if (fetched) entry.settled = true
3069
- else prefetchCache.delete(cacheKey)
3147
+ if (fetched) {
3148
+ entry.settled = true
3149
+ entry.settledTime = clientClockNow()
3150
+ } else prefetchCache.delete(cacheKey)
3070
3151
  }
3071
3152
  // Size accounting only applies to settled documents — re-trim now that
3072
3153
  // this entry's bytes count against the budget.
@@ -3997,7 +4078,6 @@ export function showLoadingShell(
3997
4078
  ) {
3998
4079
  if (sequence !== navigationSequence) return false
3999
4080
  if (typeof DOMParser === 'undefined') return false
4000
- const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
4001
4081
  const doc = new DOMParser().parseFromString(shellHtml, 'text/html')
4002
4082
  materializeClientIslandMarkers(doc)
4003
4083
  // A STATIC STAGE owns whatever chunks streamed with it, so resolve them before picking what
@@ -4035,6 +4115,10 @@ export function showLoadingShell(
4035
4115
  if (!markerRange && slotContainer === document.body && !scopedOwner) return false
4036
4116
  const container = scopedOwner ?? slotContainer
4037
4117
  const fragment = document.createDocumentFragment()
4118
+ // A live page slot is filled from the incoming page slot, never from a container inside it.
4119
+ // compat.next keeps its own fill, which its nested parallel-slot layouts rely on.
4120
+ const incomingSlot =
4121
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && markerRange ? pageSlotRange(doc.body) : null
4038
4122
  // The streamed document can already contain an ancestor layout that resolved before the
4039
4123
  // loading boundary. Keep that prefix when painting the fallback: replacing the target
4040
4124
  // with only the suspense children loses the eagerly prefetched layout.
@@ -4043,17 +4127,19 @@ export function showLoadingShell(
4043
4127
  (container.id ? doc.getElementById(container.id) : null) ??
4044
4128
  doc.querySelector('[data-pnext-root]') ??
4045
4129
  (container === document.body ? doc.body : null)
4046
- const sourceNodes = incomingTarget
4047
- ? [...incomingTarget.childNodes]
4048
- : inline
4049
- ? // The inline anchor is whatever element holds the markers (often <body>);
4050
- // only the fallback BETWEEN them is this boundary's content.
4051
- inline.nodes
4052
- : suspense
4053
- ? [...(suspense.closest('pnext-layout[data-pnext-segment]') ?? suspense).childNodes]
4054
- : // Boundary-free markup with no page container to copy from: there is
4055
- // nothing this paint could put on screen.
4056
- null
4130
+ const sourceNodes = incomingSlot
4131
+ ? incomingSlot[2]
4132
+ : incomingTarget
4133
+ ? [...incomingTarget.childNodes]
4134
+ : inline
4135
+ ? // The inline anchor is whatever element holds the markers (often <body>);
4136
+ // only the fallback BETWEEN them is this boundary's content.
4137
+ inline.nodes
4138
+ : suspense
4139
+ ? [...(suspense.closest('pnext-layout[data-pnext-segment]') ?? suspense).childNodes]
4140
+ : // Boundary-free markup with no page container to copy from: there is
4141
+ // nothing this paint could put on screen.
4142
+ null
4057
4143
  if (!sourceNodes) return false
4058
4144
  // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
4059
4145
  // bar the instant this returns true), so the window route state must reflect the
@@ -4085,7 +4171,6 @@ export function showLoadingShell(
4085
4171
  // useOffline(), say), and an unmounted island keeps its SSR value forever.
4086
4172
  // mountRoute is idempotent, so mounting on every paint is safe.
4087
4173
  mountPaintedIslands(doc)
4088
- if (paintHold) document.body.append(paintHold)
4089
4174
  return true
4090
4175
  }
4091
4176
 
@@ -4263,7 +4348,6 @@ function unwrapSuspenseFallbacks(root: ParentNode): void {
4263
4348
  */
4264
4349
  export function paintStaticStageSubtree(html: string): boolean {
4265
4350
  if (typeof DOMParser === 'undefined') return false
4266
- const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
4267
4351
  const doc = new DOMParser().parseFromString(html, 'text/html')
4268
4352
  if (!isPNextDocument(doc)) return false
4269
4353
  // Materialize BEFORE comparing structure: the renderer ships the page slot (and
@@ -4297,7 +4381,6 @@ export function paintStaticStageSubtree(html: string): boolean {
4297
4381
  // against the tree painted here. `swapBody`'s own body-child reuse still applies.
4298
4382
  swapBody(doc)
4299
4383
  mountPaintedIslands(doc)
4300
- if (paintHold) document.body.append(paintHold)
4301
4384
  return true
4302
4385
  }
4303
4386
 
@@ -4425,6 +4508,10 @@ function unsettledPrefetchDeadline(): Promise<null> {
4425
4508
  return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
4426
4509
  }
4427
4510
 
4511
+ // Kept copies older than REVALIDATE_AFTER_MS that core commits, then swaps for their fresh render.
4512
+ const keptCopies = new WeakMap<PrefetchedPage, Promise<PrefetchedPage | null>>()
4513
+ const REVALIDATE_AFTER_MS = 2_000
4514
+
4428
4515
  async function pageForNavigation(
4429
4516
  url: URL,
4430
4517
  options: SoftNavigateOptions = {},
@@ -4491,7 +4578,18 @@ async function pageForNavigation(
4491
4578
  cached.settled || cached.full
4492
4579
  ? await cached.page
4493
4580
  : await Promise.race([cached.page, unsettledPrefetchDeadline()])
4494
- if (page && !page.shellOnly) return page
4581
+ if (page && !page.shellOnly) {
4582
+ if (
4583
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4584
+ !prefetchStaleTimePolicy &&
4585
+ now - (cached.settledTime ?? cached.time) > REVALIDATE_AFTER_MS &&
4586
+ !documentStaticHintFromHtml(page.html)?.isStatic
4587
+ ) {
4588
+ const fresh = fetchPage(url.href, { fullRender: true }).catch(() => null)
4589
+ keptCopies.set(page, fresh)
4590
+ }
4591
+ return page
4592
+ }
4495
4593
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
4496
4594
  // the navigation issued no duplicate fetch, so paint the static stage it landed and let
4497
4595
  // the dynamic stage stream in below.
@@ -4988,6 +5086,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4988
5086
  // into the live body. Capturing later would save the target's fallback as
4989
5087
  // the previous history entry and restore a permanently stuck "Loading...".
4990
5088
  if (!options.pop) saveScrollPosition()
5089
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
5090
+ entryScroll.set(routerState.renderedEntryId, [window.scrollX, window.scrollY])
4991
5091
  if (departingBfcacheId) saveFormState(departingBfcacheId)
4992
5092
  // Snapshot the departing page's live island roots NOW, before a loading shell
4993
5093
  // can paint over (and detach) them below — they are stashed for back/forward
@@ -4996,11 +5096,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4996
5096
  // Snapshot plain root-layout DOM before a cached/streamed shell can detach it. The final swap
4997
5097
  // reconciles onto these nodes so server layouts keep the same identity Next's root reconciler does.
4998
5098
  const departingReusableBody = reusableBodyChildren()
4999
- // Capture the complete outgoing screen before any loading/static stage can replace it. The
5000
- // resolved client-root commit uses this copy only while its first complete paint settles.
5001
- const paintHoldScrollTop =
5002
- document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5003
- const paintHold = createNavigationPaintHold()
5004
5099
  // Painting a prefetched loading shell IS a committed navigation, so push the requested URL
5005
5100
  // here and let usePathname() and the address bar reflect the destination while the fetch is
5006
5101
  // still in flight; the final commit replaces this entry with the resolved one (or, on a
@@ -5056,19 +5151,14 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5056
5151
  // server-side, and re-painting would replace a committed loading state with a second,
5057
5152
  // different one. A segment's loading fallback commits ONCE per navigation.
5058
5153
  let cachedStagePainted = false
5059
- const devSoftNavigation = isDevDocument()
5154
+ const devSoftNavigation = nextDevDocument()
5060
5155
  const onShell =
5061
5156
  restorePage || options.pop || refreshLike
5062
5157
  ? undefined
5063
5158
  : (shellHtml: string) => {
5064
5159
  if (cachedStagePainted) return
5065
5160
  if (devSoftNavigation) return
5066
- // In dev, compilation can suspend an application boundary even though the route's real
5067
- // content is otherwise ready. Keep the departing page until that compile-only hole is
5068
- // resolved; an explicit loading.js boundary remains paintable through the ordinary
5069
- // loading-boundary path. Production retains streamed application-Suspense behavior.
5070
- const devNavigation = shellHtml.includes('data-pnext-dev')
5071
- if (!showLoadingShell(shellHtml, sequence, url, undefined, false, !devNavigation)) return
5161
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return
5072
5162
  // A loading boundary is a committed navigation state.
5073
5163
  pushOptimisticUrl()
5074
5164
  scheduleNavigationScroll(url, options)
@@ -5396,12 +5486,17 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5396
5486
  // page slot is a marker-range proxy); the entry's unmount compares roots by
5397
5487
  // identity, so it rides the same set.
5398
5488
  if (preservedPage) keptIslands.add(preservedPage.root as unknown as Element)
5399
- // Wake route-keyed layout children while the departing DOM is still attached. Their layout
5400
- // cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
5401
- window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
5489
+ // Copy the screen as it is now, a loading or static stage included, before anything unmounts.
5490
+ // The resolved client-root commit shows this copy only while its first complete paint settles.
5491
+ const paintHoldScrollTop =
5492
+ document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5493
+ const paintHold = createNavigationPaintHold(sequence)
5402
5494
  let focusedBeforeSwap: Element | null = null
5403
5495
  let navigationFocusTarget: HTMLElement | null = null
5404
5496
  try {
5497
+ // Wake route-keyed layout children while the departing DOM is still attached. Their layout
5498
+ // cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
5499
+ window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
5405
5500
  if (unmountSlotlessPage && departingRouteKey && departingRouteKey !== targetRouteKey) {
5406
5501
  // Cover the lifecycle-only empty-page pass. This is the same outgoing screen clone used for
5407
5502
  // the final atomic commit, attached early enough that no empty intermediate frame can paint.
@@ -5465,7 +5560,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5465
5560
  // selector waiter can observe the destination immediately after this stack unwinds; pruning in
5466
5561
  // the later post-mount phase let it catch the target node between its correct route sheet and a
5467
5562
  // stale asynchronous prune from the preceding navigation.
5468
- if (!url.hash) pruneStylesheets(doc)
5563
+ // Core keeps the departing sheets while the paint hold still shows the departing screen.
5564
+ if (!url.hash && (stylesheetReconciler || !paintHold)) pruneStylesheets(doc)
5469
5565
  // The swapped document carries the render's parallel-route state; pin it
5470
5566
  // (plus the document itself) to this history entry so back/forward restores
5471
5567
  // what was actually shown, without a server round trip.
@@ -5514,7 +5610,10 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5514
5610
  }
5515
5611
  }
5516
5612
  } finally {
5517
- await releaseNavigationPaintHold(paintHold)
5613
+ await releaseNavigationPaintHold(paintHold, sequence, () => {
5614
+ if (!url.hash && !stylesheetReconciler && sequence === navigationSequence)
5615
+ pruneStylesheets(doc)
5616
+ })
5518
5617
  }
5519
5618
  if (sequence !== navigationSequence) return
5520
5619
  // Mounting/location subscribers run after the initial pre-mount scroll action.
@@ -5547,6 +5646,19 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5547
5646
  if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5548
5647
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
5549
5648
  }
5649
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && keptCopies.has(page))
5650
+ void revalidateCommittedPage(targetUrl, sequence, page)
5651
+ }
5652
+
5653
+ async function revalidateCommittedPage(url: URL, sequence: number, page: PrefetchedPage) {
5654
+ const fresh = await keptCopies.get(page)
5655
+ if (!fresh?.ok || fresh.html === page.html || sequence !== navigationSequence) return
5656
+ await softNavigate(url.href, {
5657
+ replace: true,
5658
+ scroll: false,
5659
+ refreshLike: true,
5660
+ cachedPage: fresh,
5661
+ })
5550
5662
  }
5551
5663
 
5552
5664
  function isBotUserAgent() {
@@ -6103,5 +6215,7 @@ export function installRouterFull() {
6103
6215
  )
6104
6216
  window.addEventListener('popstate', onPopState)
6105
6217
  window.addEventListener('pageshow', onPageShow)
6218
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !navigationScrollAction)
6219
+ setNavigationScrollAction(coreNavigationScroll)
6106
6220
  watchEagerPrefetchLinks()
6107
6221
  }
@@ -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
@@ -387,6 +387,8 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
387
387
  // the defaults + resolver from every client bundle.
388
388
  __PNEXT_IMAGE_CONFIG_INLINE__: JSON.stringify(getImagesConfig()),
389
389
  __PNEXT_IMAGE_CONFIG_INLINED__: 'true',
390
+ // Next's router policies replace core's scroll and refresh ones, so client bundles drop those.
391
+ ...(nextCompatEnabled(config) ? { __PNEXT_NEXT_ROUTER__: 'true' } : {}),
390
392
  // publicRuntimeConfig inlined the same way; omitted entirely when unset so an app that
391
393
  // never configures it adds zero bytes (unlike images, there is no default to drop).
392
394
  ...(Object.keys(publicRuntimeConfig()).length > 0
@@ -7001,7 +7001,11 @@ function unwrapUnusedPageSlot(html: string, mode: PageSlotMarkerMode): string {
7001
7001
  return html.replace(/<!--pnext-page:[^>]*-->/, '<!--pnext-page:-->')
7002
7002
  }
7003
7003
  const slot = pageSlotSpan(html)
7004
- return slot ? html.slice(0, slot.start) + slot.children + html.slice(slot.end) : html
7004
+ if (!slot) return html
7005
+ // `keep` leaves the empty anchors a soft navigation grafts a kept layout's next page between.
7006
+ const children =
7007
+ mode === 'keep' ? `<!--pnext-page:-->${slot.children}<!--/pnext-page-->` : slot.children
7008
+ return html.slice(0, slot.start) + children + html.slice(slot.end)
7005
7009
  }
7006
7010
 
7007
7011
  function serializeNeutralClientIslands(html: string) {
@@ -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(root, file) || !compiledScriptFilePattern.test(file)) return undefined
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
- if (!isInside(root, target)) {
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
- const emittedRoot = compiledArtifactProfileRoot(target)
1486
- // A different profile is produced by a separate build phase. Only a
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