@wular/pnext 0.1.1 → 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.1",
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": {
@@ -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[]) {
@@ -1118,6 +1118,10 @@ type NavigationPaintHold = HTMLElement & {
1118
1118
  __pnextObserver?: MutationObserver
1119
1119
  /** The window scroll when the copy was taken. */
1120
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
1121
1125
  }
1122
1126
 
1123
1127
  function removeNavigationPaintHold(hold: NavigationPaintHold) {
@@ -1125,14 +1129,19 @@ function removeNavigationPaintHold(hold: NavigationPaintHold) {
1125
1129
  hold.remove()
1126
1130
  }
1127
1131
 
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)
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
+ }
1136
1145
  const roots = [...document.body.children].filter(
1137
1146
  element =>
1138
1147
  !element.hasAttribute('data-pnext-navigation-paint-hold') &&
@@ -1145,7 +1154,10 @@ function createNavigationPaintHold(): NavigationPaintHold | null {
1145
1154
  hold.setAttribute('aria-hidden', 'true')
1146
1155
  hold.style.cssText =
1147
1156
  'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
1148
- hold.__pnextScroll = [window.scrollX, window.scrollY]
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
1149
1161
  // A <body> shell over the whole hold: body styles lay out and paint the copy as the page was.
1150
1162
  const body = document.body.cloneNode() as HTMLElement
1151
1163
  body.style.minHeight = '100%'
@@ -1181,15 +1193,13 @@ function attachNavigationPaintHold(
1181
1193
  // Keep that live tree visible to focus management and selector observers;
1182
1194
  // hiding <body> also hides every real destination element from browser gates.
1183
1195
  placeNavigationPaintHold(hold)
1196
+ // A newer navigation leaves the hold up: its commit takes it over, or this release removes it.
1184
1197
  hold.__pnextObserver ??= new MutationObserver(() => {
1185
- if (sequence !== navigationSequence) {
1186
- return removeNavigationPaintHold(hold)
1187
- }
1188
1198
  if (!hold.isConnected) placeNavigationPaintHold(hold)
1189
1199
  })
1190
1200
  hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
1191
1201
  const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
1192
- if (scrollRoot) scrollRoot.scrollTop = scrollTop
1202
+ if (scrollRoot) scrollRoot.scrollTop = hold.__pnextScrollTop ??= scrollTop
1193
1203
  }
1194
1204
 
1195
1205
  /** Append the hold scrolled as the departing page was; appending resets an element's scroll. */
@@ -1198,7 +1208,11 @@ function placeNavigationPaintHold(hold: NavigationPaintHold) {
1198
1208
  if (hold.__pnextScroll) hold.scrollTo?.(...hold.__pnextScroll)
1199
1209
  }
1200
1210
 
1201
- async function releaseNavigationPaintHold(hold: NavigationPaintHold | null, reveal?: () => void) {
1211
+ async function releaseNavigationPaintHold(
1212
+ hold: NavigationPaintHold | null,
1213
+ sequence: number,
1214
+ reveal?: () => void,
1215
+ ) {
1202
1216
  if (!hold) return
1203
1217
  // mountRoute, client effects and the navigation commit have completed before
1204
1218
  // this runs. Keep the departing frame through the next rendering opportunity,
@@ -1206,8 +1220,17 @@ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null, reve
1206
1220
  if (window.requestAnimationFrame) {
1207
1221
  await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
1208
1222
  }
1223
+ if (hold.__pnextSequence !== sequence) return
1209
1224
  reveal?.()
1210
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)
1211
1234
  }
1212
1235
 
1213
1236
  /**
@@ -1581,10 +1604,13 @@ declare const __PNEXT_NEXT_ROUTER__: boolean | undefined
1581
1604
  // returns to where its entry was left.
1582
1605
  const coreNavigationScroll: NavigationScrollAction = (url, { pop, scroll }) => {
1583
1606
  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)))
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)))
1588
1614
  if (target) target.scrollIntoView()
1589
1615
  else if (pop || scroll !== false) window.scrollTo(x, y)
1590
1616
  }
@@ -2317,6 +2343,8 @@ export function rearmVisiblePrefetches(): void {
2317
2343
  const retry = () => {
2318
2344
  if (generation !== revalidationPrefetchGeneration) return
2319
2345
  revalidationPrefetchBlocked = false
2346
+ // A test host may tear the window down before the timer fires.
2347
+ if (typeof window === 'undefined') return
2320
2348
  for (const element of prefetchedElements) {
2321
2349
  if (!element.isConnected) {
2322
2350
  prefetchedElements.delete(element)
@@ -2567,6 +2595,8 @@ export interface PrefetchEntry {
2567
2595
  stateKey: string
2568
2596
  /** A pending prefetch always dedupes; settled stale data must refetch. */
2569
2597
  settled: boolean
2598
+ /** When the response landed, for entries that started as a pending prefetch. */
2599
+ settledTime?: number
2570
2600
  /** Byte size of the settled document (LRU size accounting). */
2571
2601
  bytes?: number
2572
2602
  /** Settled as a shell-only (partial prefetch) response — see PrefetchedPage. */
@@ -3114,8 +3144,10 @@ export function prefetchRoute(
3114
3144
  // Null = task stopped short with no data; a settled null would dedupe
3115
3145
  // every later reveal into null, so drop the entry and let a viewport
3116
3146
  // re-entry reschedule.
3117
- if (fetched) entry.settled = true
3118
- else prefetchCache.delete(cacheKey)
3147
+ if (fetched) {
3148
+ entry.settled = true
3149
+ entry.settledTime = clientClockNow()
3150
+ } else prefetchCache.delete(cacheKey)
3119
3151
  }
3120
3152
  // Size accounting only applies to settled documents — re-trim now that
3121
3153
  // this entry's bytes count against the budget.
@@ -4083,6 +4115,10 @@ export function showLoadingShell(
4083
4115
  if (!markerRange && slotContainer === document.body && !scopedOwner) return false
4084
4116
  const container = scopedOwner ?? slotContainer
4085
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
4086
4122
  // The streamed document can already contain an ancestor layout that resolved before the
4087
4123
  // loading boundary. Keep that prefix when painting the fallback: replacing the target
4088
4124
  // with only the suspense children loses the eagerly prefetched layout.
@@ -4091,17 +4127,19 @@ export function showLoadingShell(
4091
4127
  (container.id ? doc.getElementById(container.id) : null) ??
4092
4128
  doc.querySelector('[data-pnext-root]') ??
4093
4129
  (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
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
4105
4143
  if (!sourceNodes) return false
4106
4144
  // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
4107
4145
  // bar the instant this returns true), so the window route state must reflect the
@@ -4470,8 +4508,8 @@ function unsettledPrefetchDeadline(): Promise<null> {
4470
4508
  return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
4471
4509
  }
4472
4510
 
4473
- // Kept copies older than REVALIDATE_AFTER_MS that core commits at once, then refreshes in place.
4474
- const keptCopies = new WeakSet<PrefetchedPage>()
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>>()
4475
4513
  const REVALIDATE_AFTER_MS = 2_000
4476
4514
 
4477
4515
  async function pageForNavigation(
@@ -4544,9 +4582,12 @@ async function pageForNavigation(
4544
4582
  if (
4545
4583
  typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4546
4584
  !prefetchStaleTimePolicy &&
4547
- now - cached.time > REVALIDATE_AFTER_MS
4548
- )
4549
- keptCopies.add(page)
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
+ }
4550
4591
  return page
4551
4592
  }
4552
4593
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
@@ -5449,13 +5490,13 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5449
5490
  // The resolved client-root commit shows this copy only while its first complete paint settles.
5450
5491
  const paintHoldScrollTop =
5451
5492
  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)
5493
+ const paintHold = createNavigationPaintHold(sequence)
5456
5494
  let focusedBeforeSwap: Element | null = null
5457
5495
  let navigationFocusTarget: HTMLElement | null = null
5458
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)
5459
5500
  if (unmountSlotlessPage && departingRouteKey && departingRouteKey !== targetRouteKey) {
5460
5501
  // Cover the lifecycle-only empty-page pass. This is the same outgoing screen clone used for
5461
5502
  // the final atomic commit, attached early enough that no empty intermediate frame can paint.
@@ -5569,7 +5610,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5569
5610
  }
5570
5611
  }
5571
5612
  } finally {
5572
- await releaseNavigationPaintHold(paintHold, () => {
5613
+ await releaseNavigationPaintHold(paintHold, sequence, () => {
5573
5614
  if (!url.hash && !stylesheetReconciler && sequence === navigationSequence)
5574
5615
  pruneStylesheets(doc)
5575
5616
  })
@@ -5605,16 +5646,12 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5605
5646
  if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5606
5647
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
5607
5648
  }
5608
- if (
5609
- typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
5610
- keptCopies.has(page) &&
5611
- !documentStaticHintFromHtml(page.html)?.isStatic
5612
- )
5649
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && keptCopies.has(page))
5613
5650
  void revalidateCommittedPage(targetUrl, sequence, page)
5614
5651
  }
5615
5652
 
5616
5653
  async function revalidateCommittedPage(url: URL, sequence: number, page: PrefetchedPage) {
5617
- const fresh = await fetchPage(url.href, { fullRender: true }).catch(() => null)
5654
+ const fresh = await keptCopies.get(page)
5618
5655
  if (!fresh?.ok || fresh.html === page.html || sequence !== navigationSequence) return
5619
5656
  await softNavigate(url.href, {
5620
5657
  replace: true,
@@ -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