@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. 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.
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
- | { 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[]) {
@@ -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(doc: Document): boolean {
133
- const current = documentRouteState(document)
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
- function createNavigationPaintHold(): NavigationPaintHold | null {
1129
- // A newer navigation may start before the prior hold's settling frame. Retire
1130
- // that transaction synchronously so holds never nest (the older async release
1131
- // becomes a no-op).
1132
- const priorHold = document.querySelector<NavigationPaintHold>(
1133
- '[data-pnext-navigation-paint-hold]',
1134
- )
1135
- if (priorHold) removeNavigationPaintHold(priorHold)
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
- hold.__pnextScroll = [window.scrollX, window.scrollY]
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(hold: NavigationPaintHold | null, reveal?: () => void) {
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 [x = 0, y = 0] = pop
1585
- ? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined) ?? [])
1586
- : []
1587
- const target = !pop && url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
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) entry.settled = true
3118
- else prefetchCache.delete(cacheKey)
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 ? await readStreamedBody(response, options.onShell) : await response.text())
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 = incomingTarget
4095
- ? [...incomingTarget.childNodes]
4096
- : inline
4097
- ? // The inline anchor is whatever element holds the markers (often <body>);
4098
- // only the fallback BETWEEN them is this boundary's content.
4099
- inline.nodes
4100
- : suspense
4101
- ? [...(suspense.closest('pnext-layout[data-pnext-segment]') ?? suspense).childNodes]
4102
- : // Boundary-free markup with no page container to copy from: there is
4103
- // nothing this paint could put on screen.
4104
- null
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
- // Kept copies older than REVALIDATE_AFTER_MS that core commits at once, then refreshes in place.
4474
- const keptCopies = new WeakSet<PrefetchedPage>()
4475
- const REVALIDATE_AFTER_MS = 2_000
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
- onShell?: (shellHtml: string) => void,
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 cached = prefetchEntriesForNavigation(key).find(
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
- : await Promise.race([cached.page, unsettledPrefetchDeadline()])
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
- now - cached.time > REVALIDATE_AFTER_MS
4548
- )
4549
- keptCopies.add(page)
4550
- return page
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 (devSoftNavigation) return
5120
- if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return
5121
- // A loading boundary is a committed navigation state.
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
- pushOptimisticUrl()
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
- pushOptimisticUrl()
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
@@ -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