@wular/pnext 0.0.19 → 0.0.21

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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
@@ -19,6 +19,7 @@ import { noteModuleGeneration, noteModuleImported } from './module-generations'
19
19
  import {
20
20
  rewriteServerSource,
21
21
  serverBundleTargetForRuntime,
22
+ serverBundleConditions,
22
23
  serverBundleRequireConditions,
23
24
  type ServerBundleTarget,
24
25
  } from './loader'
@@ -350,6 +351,59 @@ function graphEdges(config: ResolvedConfig, file: string, source: string) {
350
351
  return { imports, packages, client: hasUseClientDirective(source) }
351
352
  }
352
353
 
354
+ /**
355
+ * No build ever waits for another build. Compiling a module needs its imports' artifact PATHS, which
356
+ * are a pure function of the source graph - never their bytes - so a compile registers what it
357
+ * reaches and moves on. That removes the deadlock class outright: real app graphs are cyclic, and any
358
+ * scheme where one build awaits another lets two of them each hold what the other needs, which stalls
359
+ * the request with no error until the socket idles out.
360
+ *
361
+ * The one real requirement is temporal and belongs at the boundary: no href may reach `import()`
362
+ * before everything reachable from it is on disk. An entry therefore waits for quiescence, not for a
363
+ * closure it can compute: with parents no longer written after their children, an artifact already on
364
+ * disk says nothing about its imports, so "what this entry reached" is not a set anyone can name.
365
+ *
366
+ * The wait runs off an append-only log rather than the in-flight map, so a build that starts AND
367
+ * settles between two looks is still waited on - and its failure still reaches the caller instead of
368
+ * disappearing with the map entry.
369
+ *
370
+ * Waiting is global; FAILING is not. An entry only rethrows for builds its own walk started or
371
+ * joined, so one request's broken module cannot 500 an unrelated request that merely overlapped it.
372
+ */
373
+ const buildLog: Promise<string>[] = []
374
+ const walkBuilds = new WeakMap<Set<string>, Set<Promise<string>>>()
375
+ let drainsActive = 0
376
+
377
+ /** The builds this walk owns - keyed on the walk's own `visited` set, which already identifies it. */
378
+ function ownedBuilds(visited: Set<string>) {
379
+ let owned = walkBuilds.get(visited)
380
+ if (!owned) {
381
+ owned = new Set()
382
+ walkBuilds.set(visited, owned)
383
+ }
384
+ return owned
385
+ }
386
+
387
+ function registerBuild(build: Promise<string>) {
388
+ buildLog.push(build)
389
+ }
390
+
391
+ async function drainModuleBuilds(visited: Set<string>) {
392
+ drainsActive += 1
393
+ try {
394
+ for (let index = 0; index < buildLog.length;) {
395
+ const pending = buildLog.slice(index)
396
+ index = buildLog.length
397
+ await Promise.allSettled(pending)
398
+ }
399
+ await Promise.all([...(walkBuilds.get(visited) ?? [])])
400
+ } finally {
401
+ drainsActive -= 1
402
+ // Nothing in flight and nobody mid-drain: the log has served its purpose and can go.
403
+ if (drainsActive === 0 && moduleBuilds.size === 0) buildLog.length = 0
404
+ }
405
+ }
406
+
353
407
  function withBuildSlot<T>(task: () => Promise<T>): Promise<T> {
354
408
  return new Promise<T>((resolve, reject) => {
355
409
  const run = () => {
@@ -395,21 +449,27 @@ export async function devServerModuleHref(
395
449
  const reactServerLayer =
396
450
  moduleOptions.reactServerLayer ?? (conditionTarget === 'server' || conditionTarget === 'edge')
397
451
  const release = devModuleGraph(config).hold()
398
- const compiled = await writeDevModule(config, file, new Set(), {
399
- aliases: {
400
- ...coreAliases(config, 'server'),
401
- ...(reactServerLayer ? getImportAliasExtensions().reactServerLayerAliases(config) : {}),
402
- },
403
- profile: reactCompatEnabled(config) ? 'compat' : 'server',
404
- conditionTarget,
405
- reactServerLayer,
406
- externalLoadTarget:
407
- moduleOptions.externalLoadTarget ?? externalLoadTargetForConditionTarget(conditionTarget),
408
- stubClientImports: reactCompatEnabled(config),
409
- rewriteExternalServerImports: true,
410
- bundleExternalPackages: reactCompatEnabled(config),
411
- }).finally(release)
412
- return pathToFileHref(compiled)
452
+ const visited = new Set<string>()
453
+ try {
454
+ const compiled = await writeDevModule(config, file, visited, {
455
+ aliases: {
456
+ ...coreAliases(config, 'server'),
457
+ ...(reactServerLayer ? getImportAliasExtensions().reactServerLayerAliases(config) : {}),
458
+ },
459
+ profile: reactCompatEnabled(config) ? 'compat' : 'server',
460
+ conditionTarget,
461
+ reactServerLayer,
462
+ externalLoadTarget:
463
+ moduleOptions.externalLoadTarget ?? externalLoadTargetForConditionTarget(conditionTarget),
464
+ stubClientImports: reactCompatEnabled(config),
465
+ rewriteExternalServerImports: true,
466
+ bundleExternalPackages: reactCompatEnabled(config),
467
+ })
468
+ await drainModuleBuilds(visited)
469
+ return pathToFileHref(compiled)
470
+ } finally {
471
+ release()
472
+ }
413
473
  }
414
474
 
415
475
  export async function devClientModuleHref(
@@ -422,13 +482,19 @@ export async function devClientModuleHref(
422
482
  const throughPackage = await packageClientModuleHref(config, file, conditionTarget)
423
483
  if (throughPackage) return throughPackage
424
484
  const release = devModuleGraph(config).hold()
425
- const compiled = await writeDevModule(
426
- config,
427
- file,
428
- new Set(),
429
- clientLayerOptions(config, conditionTarget),
430
- ).finally(release)
431
- return pathToFileHref(compiled)
485
+ const visited = new Set<string>()
486
+ try {
487
+ const compiled = await writeDevModule(
488
+ config,
489
+ file,
490
+ visited,
491
+ clientLayerOptions(config, conditionTarget),
492
+ )
493
+ await drainModuleBuilds(visited)
494
+ return pathToFileHref(compiled)
495
+ } finally {
496
+ release()
497
+ }
432
498
  }
433
499
 
434
500
  /**
@@ -1056,8 +1122,14 @@ async function writeDevModule(
1056
1122
  // the life of the process.
1057
1123
  if (devArtifactUsable(outFile)) return outFile
1058
1124
 
1059
- const existing = moduleBuilds.get(outFile)
1060
- if (existing) return existing
1125
+ // Already being built: its artifact path is all this caller needs, and the entry's drain is what
1126
+ // waits for the bytes. Awaiting it here is what used to deadlock two walks meeting a cycle. The
1127
+ // walk still OWNS it - its graph names that artifact, so its failure is this entry's failure.
1128
+ const inFlight = moduleBuilds.get(outFile)
1129
+ if (inFlight) {
1130
+ ownedBuilds(visited).add(inFlight)
1131
+ return outFile
1132
+ }
1061
1133
 
1062
1134
  visited.add(visitedKey)
1063
1135
  const started = moduleStats ? performance.now() : 0
@@ -1073,15 +1145,16 @@ async function writeDevModule(
1073
1145
  }
1074
1146
  return result
1075
1147
  })
1076
- .catch(error => {
1077
- moduleBuilds.delete(outFile)
1078
- throw error
1079
- })
1080
1148
  .finally(() => {
1081
- moduleBuilds.delete(outFile)
1149
+ // Identity-guarded: a rebuild may already have claimed this path.
1150
+ if (moduleBuilds.get(outFile) === next) moduleBuilds.delete(outFile)
1082
1151
  })
1152
+ // Only a drain consumes the rejection; this keeps it from surfacing as an unhandled one meanwhile.
1153
+ next.catch(() => undefined)
1154
+ registerBuild(next)
1155
+ ownedBuilds(visited).add(next)
1083
1156
  moduleBuilds.set(outFile, next)
1084
- return next
1157
+ return outFile
1085
1158
  }
1086
1159
 
1087
1160
  async function writeDevModuleUncached(
@@ -1160,15 +1233,16 @@ async function writeDevModuleUncached(
1160
1233
  !hasUseClientDirective(shakenSource)
1161
1234
  ? await writeClientReferenceModules(shakenSource, config, file, effectiveOptions.aliases)
1162
1235
  : { specifiers: new Map<string, string>(), files: new Set<string>() }
1163
- // Compiling this module only needs its imports' *paths*, so it runs while the
1164
- // targets themselves compile. The write still waits for them: a compiled
1165
- // module must never become loadable before its edges exist on disk.
1236
+ // Compiling this module only needs its imports' *paths*, so it runs while the targets themselves
1237
+ // compile. Registering the edges is all this awaits; drainModuleBuilds is what holds an href back
1238
+ // until they are on disk.
1166
1239
  const compiled = buildDevModuleSource(
1167
1240
  shakenSource,
1168
1241
  config,
1169
1242
  file,
1170
1243
  clientReferences.specifiers,
1171
1244
  effectiveOptions,
1245
+ visited,
1172
1246
  )
1173
1247
  // The real rejection is reported below; this only keeps it from surfacing as
1174
1248
  // an unhandled rejection while the imports compile.
@@ -1290,8 +1364,7 @@ async function writeShakenDynamicModule(
1290
1364
  if (devArtifactUsable(outFile)) return outFile
1291
1365
 
1292
1366
  const key = `shake\0${outFile}`
1293
- const existing = moduleBuilds.get(key)
1294
- if (existing) return existing
1367
+ if (moduleBuilds.has(key)) return outFile
1295
1368
 
1296
1369
  const next = writeShakenDynamicModuleUncached(
1297
1370
  config,
@@ -1300,16 +1373,13 @@ async function writeShakenDynamicModule(
1300
1373
  visited,
1301
1374
  options,
1302
1375
  outFile,
1303
- )
1304
- .catch(error => {
1305
- moduleBuilds.delete(key)
1306
- throw error
1307
- })
1308
- .finally(() => {
1309
- moduleBuilds.delete(key)
1310
- })
1376
+ ).finally(() => {
1377
+ if (moduleBuilds.get(key) === next) moduleBuilds.delete(key)
1378
+ })
1379
+ next.catch(() => undefined)
1380
+ registerBuild(next)
1311
1381
  moduleBuilds.set(key, next)
1312
- return next
1382
+ return outFile
1313
1383
  }
1314
1384
 
1315
1385
  async function writeShakenDynamicModuleUncached(
@@ -1336,32 +1406,37 @@ async function writeShakenDynamicModuleUncached(
1336
1406
  .map(({ target }) => writeDevModule(config, target, visited, options)),
1337
1407
  )
1338
1408
  const externalBundles = await externalBundleSpecifiers(targetSource, config, targetFile, options)
1339
- const result = await withBuildSlot(() =>
1340
- build({
1341
- stdin: {
1342
- contents: shakenEntrySource(usedExports),
1343
- loader: 'js',
1344
- resolveDir: path.dirname(targetFile),
1345
- sourcefile: `${targetFile}.pnext-shake`,
1346
- },
1347
- bundle: true,
1348
- write: false,
1349
- format: 'esm',
1350
- platform: 'neutral',
1351
- target: 'es2022',
1352
- jsx: 'automatic',
1353
- jsxImportSource: 'preact',
1354
- packages: 'external',
1355
- logLevel: 'silent',
1356
- ...serverDefineOptions(),
1357
- plugins: [
1358
- shakeEntryPlugin(targetFile, targetSource),
1359
- serverAssetPlugin(config),
1360
- ...getBundlerExtensions().serverEsbuildPlugins(config),
1361
- devModuleResolvePlugin(config, targetFile, new Map(), externalBundles, options),
1362
- ],
1363
- }),
1364
- )
1409
+ const shake = () =>
1410
+ withBuildSlot(() =>
1411
+ build({
1412
+ stdin: {
1413
+ contents: shakenEntrySource(usedExports),
1414
+ loader: 'js',
1415
+ resolveDir: path.dirname(targetFile),
1416
+ sourcefile: `${targetFile}.pnext-shake`,
1417
+ },
1418
+ bundle: true,
1419
+ write: false,
1420
+ format: 'esm',
1421
+ platform: 'neutral',
1422
+ target: 'es2022',
1423
+ jsx: 'automatic',
1424
+ jsxImportSource: 'preact',
1425
+ packages: 'external',
1426
+ logLevel: 'silent',
1427
+ ...serverDefineOptions(),
1428
+ plugins: [
1429
+ shakeEntryPlugin(targetFile, targetSource),
1430
+ serverAssetPlugin(config),
1431
+ ...getBundlerExtensions().serverEsbuildPlugins(config),
1432
+ devModuleResolvePlugin(config, targetFile, new Map(), externalBundles, options, visited),
1433
+ ],
1434
+ }),
1435
+ )
1436
+ // The facade inlines the target outright, so its package imports are always late ones. Same shape
1437
+ // as buildDevModuleSource: vendor off the slot, then shake again, to a fixpoint.
1438
+ let result = await shake()
1439
+ while (await vendorLateExternalPackages(config, options, externalBundles)) result = await shake()
1365
1440
  const output = result.outputFiles[0]
1366
1441
  if (!output) throw new Error(`Failed to tree-shake ${targetFile}`)
1367
1442
  await writeCompiledFile(outFile, addExtensionlessDynamicImportGlobAliases(output.text))
@@ -1460,8 +1535,48 @@ async function buildDevModuleSource(
1460
1535
  file: string,
1461
1536
  clientReferenceSpecifiers: Map<string, string>,
1462
1537
  options: DevModuleOptions,
1538
+ visited: Set<string>,
1463
1539
  ) {
1464
1540
  const externalBundles = await externalBundleSpecifiers(source, config, file, options)
1541
+ let compiled = await compileDevModuleSource(
1542
+ source,
1543
+ config,
1544
+ file,
1545
+ clientReferenceSpecifiers,
1546
+ externalBundles,
1547
+ options,
1548
+ visited,
1549
+ )
1550
+ // Vendoring for a specifier only an INLINED module asked for happens here - between compiles, with
1551
+ // no build slot held - and the module is compiled again with the map filled in. Doing it from the
1552
+ // resolve callback instead would hold a slot for the length of a vendor build.
1553
+ //
1554
+ // To a FIXPOINT, not once: vendoring a specifier changes what the next compile inlines, so round N
1555
+ // can expose a package round N-1 never saw. It terminates because every round records at least one
1556
+ // new key in `externalBundles` and a recorded specifier is never noted again.
1557
+ while (await vendorLateExternalPackages(config, options, externalBundles)) {
1558
+ compiled = await compileDevModuleSource(
1559
+ source,
1560
+ config,
1561
+ file,
1562
+ clientReferenceSpecifiers,
1563
+ externalBundles,
1564
+ options,
1565
+ visited,
1566
+ )
1567
+ }
1568
+ return compiled
1569
+ }
1570
+
1571
+ async function compileDevModuleSource(
1572
+ source: string,
1573
+ config: ResolvedConfig,
1574
+ file: string,
1575
+ clientReferenceSpecifiers: Map<string, string>,
1576
+ externalBundles: Map<string, string>,
1577
+ options: DevModuleOptions,
1578
+ visited: Set<string>,
1579
+ ) {
1465
1580
  const transformed = await transformDevModuleSource(
1466
1581
  source,
1467
1582
  config,
@@ -1469,6 +1584,7 @@ async function buildDevModuleSource(
1469
1584
  clientReferenceSpecifiers,
1470
1585
  externalBundles,
1471
1586
  options,
1587
+ visited,
1472
1588
  )
1473
1589
  if (transformed !== undefined) return transformed
1474
1590
  if (moduleStats) esbuildFallbacks.add(`${devModuleProfile(options)}:${file}`)
@@ -1497,7 +1613,14 @@ async function buildDevModuleSource(
1497
1613
  serverAssetPlugin(config),
1498
1614
  ...getBundlerExtensions().serverEsbuildPlugins(config),
1499
1615
  serverClientReferencePlugin(config, options),
1500
- devModuleResolvePlugin(config, file, clientReferenceSpecifiers, externalBundles, options),
1616
+ devModuleResolvePlugin(
1617
+ config,
1618
+ file,
1619
+ clientReferenceSpecifiers,
1620
+ externalBundles,
1621
+ options,
1622
+ visited,
1623
+ ),
1501
1624
  ],
1502
1625
  }),
1503
1626
  )
@@ -1522,6 +1645,7 @@ async function transformDevModuleSource(
1522
1645
  clientReferenceSpecifiers: Map<string, string>,
1523
1646
  externalBundles: Map<string, string>,
1524
1647
  options: DevModuleOptions,
1648
+ visited: Set<string>,
1525
1649
  ): Promise<string | undefined> {
1526
1650
  // Kill switch: puts every module back on the esbuild build below, for
1527
1651
  // bisecting a suspected transform-path difference.
@@ -1542,6 +1666,7 @@ async function transformDevModuleSource(
1542
1666
  clientReferenceSpecifiers,
1543
1667
  externalBundles,
1544
1668
  options,
1669
+ visited,
1545
1670
  )
1546
1671
  if (target === undefined) return undefined
1547
1672
  if (target !== found.value)
@@ -1563,6 +1688,7 @@ async function devModuleSpecifierTarget(
1563
1688
  clientReferenceSpecifiers: Map<string, string>,
1564
1689
  externalBundles: Map<string, string>,
1565
1690
  options: DevModuleOptions,
1691
+ visited: Set<string>,
1566
1692
  ): Promise<string | undefined> {
1567
1693
  // Same claim `serverAssetPlugin` makes, and in the same order: it is the first
1568
1694
  // onResolve in the build below.
@@ -1583,6 +1709,7 @@ async function devModuleSpecifierTarget(
1583
1709
  options,
1584
1710
  kind,
1585
1711
  file,
1712
+ visited,
1586
1713
  )
1587
1714
  // A non-external result names a namespace the bundler loads inline
1588
1715
  // (`pnext-module-external` facades) or a bare require-alias path.
@@ -1633,6 +1760,7 @@ function devModuleResolvePlugin(
1633
1760
  clientReferenceSpecifiers: Map<string, string>,
1634
1761
  externalBundles: Map<string, string>,
1635
1762
  options: DevModuleOptions,
1763
+ visited: Set<string>,
1636
1764
  ): Plugin {
1637
1765
  return {
1638
1766
  name: 'pnext-dev-module-resolve',
@@ -1648,6 +1776,7 @@ function devModuleResolvePlugin(
1648
1776
  options,
1649
1777
  args.kind,
1650
1778
  args.importer,
1779
+ visited,
1651
1780
  ),
1652
1781
  )
1653
1782
  },
@@ -1719,6 +1848,7 @@ async function resolveDevModuleSpecifier(
1719
1848
  options: DevModuleOptions,
1720
1849
  kind: string,
1721
1850
  importer: string,
1851
+ visited: Set<string>,
1722
1852
  ): Promise<OnResolveResult> {
1723
1853
  // An already-resolved `file://` href is a compiled module on disk; pass it straight through as
1724
1854
  // external instead of re-resolving it. But a file:// href naming a RAW 'use client' source
@@ -1790,6 +1920,12 @@ async function resolveDevModuleSpecifier(
1790
1920
  }
1791
1921
 
1792
1922
  const externalBundle = externalBundles.get(sourcePath)
1923
+ // A miss here is a specifier the pre-compile scan could not have seen (its importer was inlined).
1924
+ // Note it and fall through to the bare-specifier path for THIS compile; the caller vendors it off
1925
+ // the build slot and compiles once more with the map filled in.
1926
+ if (externalBundle === undefined) {
1927
+ noteLateExternalPackage(sourcePath, config, file, importer, options, externalBundles)
1928
+ }
1793
1929
  if (externalBundle && kind === 'require-call') {
1794
1930
  const required = resolvePackageSpecifier(
1795
1931
  config.root,
@@ -1852,12 +1988,12 @@ async function resolveDevModuleSpecifier(
1852
1988
  }
1853
1989
  const externalTarget =
1854
1990
  resolvedExternalLoad ?? externalPackageImportTarget(config.root, file, sourcePath)
1991
+ if (externalTarget) return { path: `${pathToFileHref(externalTarget)}${hash}`, external: true }
1992
+ if (options.rewriteExternalServerImports) {
1993
+ return { path: runtimeServerImportTarget(specifier), external: true }
1994
+ }
1855
1995
  return {
1856
- path: externalTarget
1857
- ? `${pathToFileHref(externalTarget)}${hash}`
1858
- : options.rewriteExternalServerImports
1859
- ? runtimeServerImportTarget(specifier)
1860
- : specifier,
1996
+ path: `${offLayerPackageTarget(config, file, sourcePath, kind, options)}${hash}`,
1861
1997
  external: true,
1862
1998
  }
1863
1999
  }
@@ -1909,7 +2045,7 @@ async function resolveDevModuleSpecifier(
1909
2045
  // pointing at the raw source instead would load a SECOND copy of the compat runtime graph and split its
1910
2046
  // module state. writeDevModule dedupes in-flight builds per artifact path, so a module reached from
1911
2047
  // several bundled importers is compiled once.
1912
- await writeDevModule(config, resolved, new Set(), options)
2048
+ await writeDevModule(config, resolved, visited, options)
1913
2049
  }
1914
2050
  return {
1915
2051
  path: `${pathToFileHref(await devModulePath(config, resolved, options))}${hash}`,
@@ -1917,6 +2053,53 @@ async function resolveDevModuleSpecifier(
1917
2053
  }
1918
2054
  }
1919
2055
 
2056
+ /**
2057
+ * THE LAYER RULE. An artifact whose imports the dev PROCESS resolves - anything not rewritten to a
2058
+ * runtime server target - must not carry a bare package specifier, because the process runs under
2059
+ * `--conditions=react-server` and the client-SSR (and pages-SSR) layers are not that layer: `react`
2060
+ * resolves to React 18's `react.shared-subset`, which throws on import, and a package with a
2061
+ * `react-server` export condition hands back its RSC entry.
2062
+ *
2063
+ * Vendoring is the primary answer and has already run for everything eligible (it also rewrites the
2064
+ * package's OWN `react` to this layer's shim). This is the floor beneath it, for the specifiers
2065
+ * vendoring declines - a `serverExternalPackages` opt-out, a core app with react compat off: resolve
2066
+ * the specifier HERE, under this layer's own conditions, and emit that absolute path. Costs nothing
2067
+ * at run time and adds no bytes - it is the same file, named instead of re-resolved.
2068
+ *
2069
+ * A specifier that resolves under NO condition set is left bare: it is unresolvable from this
2070
+ * importer, so the run time reports a resolve error rather than the wrong layer's module.
2071
+ */
2072
+ function offLayerPackageTarget(
2073
+ config: ResolvedConfig,
2074
+ file: string,
2075
+ specifier: string,
2076
+ kind: string,
2077
+ options: DevModuleOptions,
2078
+ ) {
2079
+ if (!isPackageSpecifier(specifier)) return specifier
2080
+ const isRequire = kind === 'require-call'
2081
+ const resolved =
2082
+ resolvePackageSpecifier(
2083
+ config.root,
2084
+ file,
2085
+ specifier,
2086
+ isRequire
2087
+ ? serverBundleRequireConditions(options.conditionTarget)
2088
+ : serverBundleConditions(options.conditionTarget),
2089
+ ) ??
2090
+ resolvePackageSpecifier(
2091
+ config.root,
2092
+ file,
2093
+ specifier,
2094
+ isRequire
2095
+ ? serverBundleConditions(options.conditionTarget)
2096
+ : serverBundleRequireConditions(options.conditionTarget),
2097
+ )
2098
+ if (!resolved) return specifier
2099
+ // `require()` takes a path; only an ESM import takes a file href.
2100
+ return isRequire ? resolved : pathToFileHref(resolved)
2101
+ }
2102
+
1920
2103
  function serverRequireAlias(specifier: string, alias: string, options: DevModuleOptions) {
1921
2104
  if (options.conditionTarget === 'client') return alias
1922
2105
  if (specifier === 'next/navigation' && alias.endsWith(`${path.sep}navigation.ts`)) {
@@ -2075,6 +2258,79 @@ async function externalBundleSpecifiers(
2075
2258
  return bundles
2076
2259
  }
2077
2260
 
2261
+ // Specifiers one compile asked for and the pre-compile scan had not vendored, keyed on that compile's
2262
+ // own `externalBundles` map - already threaded everywhere they are recorded and drained, so nothing
2263
+ // new rides through the resolve chain. Value is the importer the specifier resolves from.
2264
+ const lateExternalPackages = new WeakMap<Map<string, string>, Map<string, string>>()
2265
+
2266
+ /**
2267
+ * Record a package specifier `externalBundleSpecifiers` never saw. That scan reads the compiled
2268
+ * module's OWN source, so a specifier reaching this build from a module esbuild INLINED instead (a
2269
+ * require()d ESM sibling, a bundled compat importer) is missing from the map, and left alone it
2270
+ * survives verbatim into the artifact - where Bun resolves it from the real node_modules under the
2271
+ * server process's own `react-server` condition, and a client-only subtree (MUI -> emotion -> react)
2272
+ * reaches React 18's shared-subset entry and throws.
2273
+ *
2274
+ * Synchronous, and deliberately so. This runs inside esbuild's onResolve, which holds a build slot:
2275
+ * awaiting a vendor bundle here parks one of the few slots for however long that build takes and
2276
+ * heads off every queued module compile behind it. Nothing on this path may await a vendor build, a
2277
+ * module href, or a drain - `vendorLateExternalPackages` does the waiting, between compiles, with no
2278
+ * slot held.
2279
+ */
2280
+ function noteLateExternalPackage(
2281
+ specifier: string,
2282
+ config: ResolvedConfig,
2283
+ file: string,
2284
+ importer: string,
2285
+ options: DevModuleOptions,
2286
+ externalBundles: Map<string, string>,
2287
+ ) {
2288
+ if (!options.bundleExternalPackages) return
2289
+ if (isServerIgnoredAssetSpecifier(specifier)) return
2290
+ const fromFile =
2291
+ importer && !importer.startsWith('<') && path.isAbsolute(importer) ? importer : file
2292
+ if (!shouldBundleExternalPackage(specifier, config, fromFile, options)) return
2293
+ let pending = lateExternalPackages.get(externalBundles)
2294
+ if (!pending) {
2295
+ pending = new Map<string, string>()
2296
+ lateExternalPackages.set(externalBundles, pending)
2297
+ }
2298
+ pending.set(specifier, fromFile)
2299
+ }
2300
+
2301
+ /**
2302
+ * Vendor what the compile just recorded and fold it into its map, so recompiling rewrites those
2303
+ * specifiers. False when there was nothing to do, which is the overwhelming majority of modules.
2304
+ *
2305
+ * Failures propagate, exactly as they do from the pre-compile scan: the only other outcome is
2306
+ * emitting the bare specifier, which is the runtime `react-server` resolution this seam exists to
2307
+ * prevent - so a swallowed vendor error would silently reproduce the bug it is fixing.
2308
+ */
2309
+ async function vendorLateExternalPackages(
2310
+ config: ResolvedConfig,
2311
+ options: DevModuleOptions,
2312
+ externalBundles: Map<string, string>,
2313
+ ) {
2314
+ const pending = lateExternalPackages.get(externalBundles)
2315
+ if (!pending || pending.size === 0) return false
2316
+ lateExternalPackages.delete(externalBundles)
2317
+ await Promise.all(
2318
+ [...pending].map(async ([specifier, fromFile]) => {
2319
+ externalBundles.set(
2320
+ specifier,
2321
+ await externalServerPackageHref(
2322
+ config,
2323
+ specifier,
2324
+ options.profile === 'client' ? 'client' : 'server',
2325
+ path.dirname(fromFile),
2326
+ options.conditionTarget,
2327
+ ),
2328
+ )
2329
+ }),
2330
+ )
2331
+ return true
2332
+ }
2333
+
2078
2334
  function shouldBundleExternalPackage(
2079
2335
  specifier: string,
2080
2336
  config: ResolvedConfig,
@@ -26,7 +26,13 @@ import {
26
26
  type ServerEsbuildPluginOptions,
27
27
  } from '../extensions'
28
28
  import { pathToFileHref, type CompatAliasTarget, type ResolvedConfig } from '../config'
29
- import { devArtifactUsable, devSourceIdentity, noteDevArtifactWritten } from './module-cache'
29
+ import {
30
+ cacheRoot,
31
+ devArtifactUsable,
32
+ devSourceIdentity,
33
+ noteDevArtifactWritten,
34
+ } from './module-cache'
35
+ import { frameworkFingerprint } from './fingerprint'
30
36
  import {
31
37
  getExternalPackagePolicy,
32
38
  isEsmModuleEntry,
@@ -169,9 +175,21 @@ function vendorArtifactKey(
169
175
  conditionTarget: ServerBundleTarget,
170
176
  source: string,
171
177
  ) {
172
- return `${vendorPortablePath(config, config.outPath)}\0${target}\0${conditionTarget}\0${source}`
178
+ return `${vendorGeneration(config)}\0${vendorPortablePath(config, config.outPath)}\0${target}\0${conditionTarget}\0${source}`
173
179
  }
174
180
 
181
+ /**
182
+ * pnext's generation, in every name the vendor cache derives. A vendor bundle is pnext's OWN output
183
+ * - its CJS interop, its aliases, its defines - so one generation's bundle must never be handed to
184
+ * the next, exactly as the module cache already refuses one. The same holds for the verdict stores
185
+ * below, which decide that a demand needs no build at all: a verdict is framework logic, and a
186
+ * stale one routes today's demand onto yesterday's bytes.
187
+ *
188
+ * The fingerprint is content over pnext's shipped tree, memoized per process and recorded next to
189
+ * the module cache, so an app whose framework did not move keeps every warm hit for free.
190
+ */
191
+ const vendorGeneration = (config: ResolvedConfig) => frameworkFingerprint(cacheRoot(config.outPath))
192
+
175
193
  const vendorPortablePath = (config: ResolvedConfig, file: string) =>
176
194
  devSourceIdentity(file, config.workspaceRoot)
177
195
 
@@ -331,7 +349,18 @@ function vendorLayerKey(target: CompatAliasTarget, conditionTarget: ServerBundle
331
349
  }
332
350
 
333
351
  function vendorSharedFile(config: ResolvedConfig) {
334
- return path.join(config.outPath, 'cache', 'server', 'vendor', 'layer-shared.json')
352
+ return vendorStoreFile(config, 'layer-shared')
353
+ }
354
+
355
+ /** A vendor verdict store, named for the generation whose logic produced it. */
356
+ function vendorStoreFile(config: ResolvedConfig, name: string) {
357
+ return path.join(
358
+ config.outPath,
359
+ 'cache',
360
+ 'server',
361
+ 'vendor',
362
+ `${name}.${vendorGeneration(config)}.json`,
363
+ )
335
364
  }
336
365
 
337
366
  /** Verdicts from earlier runs, so reuse lands on the first demand of this one. */
@@ -459,7 +488,7 @@ const vendorReuseLoaded = new Set<string>()
459
488
  const vendorReuseProbes = new Map<string, Promise<boolean>>()
460
489
 
461
490
  function vendorReuseFile(config: ResolvedConfig) {
462
- return path.join(config.outPath, 'cache', 'server', 'vendor', 'reuse-verdicts.json')
491
+ return vendorStoreFile(config, 'reuse-verdicts')
463
492
  }
464
493
 
465
494
  function vendorReuseFor(config: ResolvedConfig) {
@@ -754,7 +783,7 @@ function schedulePreplanReport() {
754
783
  }
755
784
 
756
785
  function vendorPreplanFile(config: ResolvedConfig) {
757
- return path.join(config.outPath, 'cache', 'server', 'vendor', 'preplan-verdicts.json')
786
+ return vendorStoreFile(config, 'preplan-verdicts')
758
787
  }
759
788
 
760
789
  function vendorPreplanFor(config: ResolvedConfig) {