@wular/pnext 0.0.7 → 0.0.9

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.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@ import { listFiles } from '../utils/fs'
9
9
  import type { BuildManifest, RouteManifestEntry } from '../types'
10
10
 
11
11
  export interface AnalyzeResult {
12
- mode: 'production' | 'development'
13
12
  root: string
14
13
  compression: AnalyzeCompression
15
14
  files: AnalyzeFile[]
@@ -43,6 +42,10 @@ export interface AnalyzeBundleFile {
43
42
  }
44
43
 
45
44
  export interface AnalyzeDynamicBundleFile extends AnalyzeBundleFile {
45
+ /** Island's client-reference id, as emitted in the entry's island table. */
46
+ id: string
47
+ /** Display name: the reference's named export, or its module basename. */
48
+ component: string
46
49
  exportName: string
47
50
  load: 'render' | 'visible'
48
51
  }
@@ -63,11 +66,10 @@ export async function analyzeProject(
63
66
  throw new Error(`No PNext output found at ${config.outPath}. Run pnext build first.`)
64
67
  }
65
68
 
69
+ // Dev output is unminified, so its sizes say nothing about what ships; analyze is production-only.
66
70
  const target = analyzeTarget(config.outPath)
67
71
  if (!target) {
68
- throw new Error(
69
- `No PNext build or dev output found at ${config.outPath}. Run pnext build or pnext dev first.`,
70
- )
72
+ throw new Error(`No production build found at ${config.outPath}. Run pnext build first.`)
71
73
  }
72
74
 
73
75
  const compression = options.compression ?? 'gzip'
@@ -83,13 +85,9 @@ export async function analyzeProject(
83
85
  }
84
86
  }),
85
87
  )
86
- const routeBundles =
87
- target.mode === 'production'
88
- ? await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
89
- : []
88
+ const routeBundles = await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
90
89
 
91
90
  return {
92
- mode: target.mode,
93
91
  root: path.relative(config.root, target.root),
94
92
  compression,
95
93
  files: rows,
@@ -115,7 +113,9 @@ async function analyzeRouteBundles(
115
113
 
116
114
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
117
115
  const pages = manifest.routes.filter(route => route.kind === 'page')
118
- const selected = routeFilter ? filterAnalyzeRoutes(pages, routeFilter) : pages
116
+ const selected: AnalyzeRouteSelection[] = routeFilter
117
+ ? filterAnalyzeRoutes(pages, routeFilter)
118
+ : pages.map(route => ({ route }))
119
119
  if (routeFilter && selected.length === 0) {
120
120
  throw new Error(
121
121
  `No route matches ${routeFilter}. Routes:\n ${pages
@@ -135,22 +135,29 @@ async function analyzeRouteBundles(
135
135
  }
136
136
 
137
137
  const bundles: AnalyzeRouteBundle[] = []
138
- for (const route of selected) {
139
- bundles.push(await analyzeRouteBundle(route, sizes, js))
138
+ for (const { route, pathname } of selected) {
139
+ bundles.push(await analyzeRouteBundle(route, sizes, js, pathname))
140
140
  }
141
141
  return bundles
142
142
  }
143
143
 
144
+ interface AnalyzeRouteSelection {
145
+ route: RouteManifestEntry
146
+ /** Concrete pathname the filter matched, for a param route's prerendered HTML. */
147
+ pathname?: string
148
+ }
149
+
144
150
  // Accepts the route in ':id' or '[id]' template form, or a concrete pathname
145
- // (e.g. /users/ada) matched through the route patterns.
146
- function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string) {
151
+ // (e.g. /users/ada) matched through the route patterns. A concrete pathname is
152
+ // carried through so the bundle reports that page's prerendered HTML.
153
+ function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string): AnalyzeRouteSelection[] {
147
154
  const normalized = filter === '/' ? filter : filter.replace(/\/+$/, '')
148
155
  const direct = pages.filter(
149
156
  route => route.route === normalized || publicRoutePath(route.route) === normalized,
150
157
  )
151
- if (direct.length > 0) return direct
158
+ if (direct.length > 0) return direct.map(route => ({ route }))
152
159
  const matched = matchRoute(pages, normalized)
153
- return matched ? [matched.route] : []
160
+ return matched ? [{ route: matched.route, pathname: normalized }] : []
154
161
  }
155
162
 
156
163
  function publicRoutePath(route: string) {
@@ -161,14 +168,30 @@ async function analyzeRouteBundle(
161
168
  route: RouteManifestEntry,
162
169
  sizes: Map<string, AnalyzeFile>,
163
170
  js: (path: string) => Promise<string>,
171
+ pathname?: string,
164
172
  ): Promise<AnalyzeRouteBundle> {
165
173
  const initial = new Set<string>()
166
- addIfExists(initial, sizes, routeHtmlPath(route))
174
+ addIfExists(initial, sizes, routeHtmlPath(route, pathname))
167
175
  addIfExists(initial, sizes, 'assets/global.css')
168
- addIfExists(initial, sizes, `assets/${route.id}.css`)
176
+ // Mirrors the renderer's stylesheet list: compat cssChunking splits a route's
177
+ // CSS into `<id>-<n>.css` and records them in cssAssets; `<id>.css` is emitted
178
+ // only when it is unset.
179
+ for (const asset of route.cssAssets ?? (route.cssImports.length ? [`${route.id}.css`] : []))
180
+ addIfExists(initial, sizes, `assets/${asset}`)
181
+ // Every compat page loads this with a blocking <script>. The sibling
182
+ // `_ssgManifest.js` (router-fetched, never in the document) and the
183
+ // `polyfills-*.js` chunk (noModule: legacy browsers only) are deliberately
184
+ // not initial weight for a modern client.
185
+ addIfExists(initial, sizes, '_next/static/pnext/_buildManifest.js')
169
186
 
170
187
  if (route.clientEntry) {
171
188
  addIfExists(initial, sizes, route.clientEntry)
189
+ // The build records the entry's chunk closure from the esbuild metafile and
190
+ // the renderer modulepreloads exactly this set. Chunk folding hoists imports
191
+ // out of the entry module, so walking its source alone under-reports; the
192
+ // walk stays as a union for dev-style single-bundle entries, which never
193
+ // populate clientEntryImports.
194
+ for (const asset of route.clientEntryImports ?? []) addIfExists(initial, sizes, asset)
172
195
  for (const dependency of await staticDependencies(route.clientEntry, sizes, js))
173
196
  initial.add(dependency)
174
197
  }
@@ -180,17 +203,21 @@ async function analyzeRouteBundle(
180
203
 
181
204
  if (route.clientEntry && sizes.has(toPosix(route.clientEntry))) {
182
205
  const entrySource = await js(route.clientEntry)
183
- const imports = dynamicImports(entrySource, route.clientEntry)
184
- for (const imported of imports) {
185
- const reference = route.clientReferences.find(item => item.exportName === imported.exportName)
206
+ for (const island of islandImports(entrySource, route.clientEntry)) {
207
+ const row = sizes.get(island.path)
208
+ if (!row) continue
209
+ // Keyed by island id: every island's loader resolves `module.default`, so
210
+ // matching on the export name collapses them all onto one reference.
211
+ const reference = route.clientReferences.find(item => item.id === island.id)
186
212
  const load: AnalyzeDynamicBundleFile['load'] =
187
213
  reference?.dynamic?.load === 'visible' ? 'visible' : 'render'
188
- const row = sizes.get(imported.path)
189
- if (!row) continue
190
- dynamicTargets.add(imported.path)
214
+ dynamicTargets.add(island.path)
215
+ const exportName = reference?.exportName ?? 'default'
191
216
  const item: AnalyzeDynamicBundleFile = {
192
- path: imported.path,
193
- exportName: imported.exportName,
217
+ path: island.path,
218
+ id: island.id,
219
+ component: islandComponent(exportName, reference?.file, island.path),
220
+ exportName,
194
221
  load,
195
222
  rawBytes: row.rawBytes,
196
223
  compressedBytes: row.compressedBytes,
@@ -198,12 +225,22 @@ async function analyzeRouteBundle(
198
225
  if (load === 'visible') visibleDynamic.push(item)
199
226
  else dynamic.push(item)
200
227
 
201
- for (const dependency of await staticDependencies(imported.path, sizes, js)) {
228
+ // An island with its own CSS loads `assets/<referenceId>.css` alongside its
229
+ // chunk (loadIslandCss); it is deferred weight, so it belongs here rather
230
+ // than in initial.
231
+ if (reference?.cssImports?.length) addIfExists(lazyShared, sizes, `assets/${island.id}.css`)
232
+ for (const dependency of await staticDependencies(island.path, sizes, js)) {
202
233
  if (!initial.has(dependency) && !dynamicTargets.has(dependency)) lazyShared.add(dependency)
203
234
  }
204
235
  }
205
236
 
206
- for (const importedPath of dynamicImportPaths(entrySource, route.clientEntry)) {
237
+ // Same metafile closure as the renderer's low-priority preloads, unioned
238
+ // with the entry-source walk for the same reason as the static side above.
239
+ for (const asset of [
240
+ ...(route.clientDynamicImports ?? []),
241
+ ...dynamicImportPaths(entrySource, route.clientEntry),
242
+ ]) {
243
+ const importedPath = toPosix(asset)
207
244
  if (initial.has(importedPath) || dynamicTargets.has(importedPath)) continue
208
245
  if (sizes.has(importedPath)) lazyShared.add(importedPath)
209
246
  for (const dependency of await staticDependencies(importedPath, sizes, js)) {
@@ -222,6 +259,12 @@ async function analyzeRouteBundle(
222
259
  }
223
260
  }
224
261
 
262
+ function islandComponent(exportName: string, file: string | undefined, chunkPath: string) {
263
+ if (exportName !== 'default') return exportName
264
+ const source = file ?? chunkPath.replace(/-[A-Z0-9]{8}\.js$/, '.js')
265
+ return path.posix.basename(toPosix(source)).replace(/\.[^.]+$/, '')
266
+ }
267
+
225
268
  async function staticDependencies(
226
269
  entry: string,
227
270
  sizes: Map<string, AnalyzeFile>,
@@ -244,17 +287,22 @@ async function staticDependencies(
244
287
  return dependencies
245
288
  }
246
289
 
247
- function dynamicImports(source: string, from: string) {
248
- const imports: { path: string; exportName: string }[] = []
290
+ // Entries of the entry module's island table, as emitted by client/entry.ts:
291
+ // { id: "c-...", options: {...}, load: () => import("./chunk.js").then(m => m.default) }
292
+ // The CSS variant wraps the import in `Promise.all([...])`. Statically-bundled
293
+ // islands carry `Component:` instead of `load:` and are part of the entry's own
294
+ // closure, so they are not matched here.
295
+ function islandImports(source: string, from: string) {
296
+ const imports: { path: string; id: string }[] = []
249
297
  const pattern =
250
- /import\("([^"]+)"\)\.then\([A-Za-z_$][\w$]*=>[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)\)/g
298
+ /\bid\s*:\s*"([^"]+)"\s*,\s*options\s*:\s*\{[^{}]*\}\s*,\s*load\s*:\s*\(\)\s*=>\s*(?:Promise\.all\(\[\s*)?import\(\s*"([^"]+)"\s*\)/g
251
299
  let match: RegExpExecArray | null
252
300
  while ((match = pattern.exec(source))) {
253
- const specifier = match[1]
254
- const exportName = match[2]
255
- if (!specifier || !exportName) continue
301
+ const id = match[1]
302
+ const specifier = match[2]
303
+ if (!id || !specifier) continue
256
304
  const resolved = resolveBuiltImport(from, specifier)
257
- if (resolved) imports.push({ path: resolved, exportName })
305
+ if (resolved) imports.push({ path: resolved, id })
258
306
  }
259
307
  return imports
260
308
  }
@@ -306,18 +354,27 @@ function bundleFiles(paths: string[], sizes: Map<string, AnalyzeFile>) {
306
354
  .sort((a, b) => b.compressedBytes - a.compressedBytes)
307
355
  }
308
356
 
309
- function routeHtmlPath(route: RouteManifestEntry) {
310
- return route.route === '/' ? 'index.html' : `${route.route.replace(/^\/+/, '')}/index.html`
357
+ function routeHtmlPath(route: RouteManifestEntry, pathname?: string) {
358
+ const target = pathname ?? prerenderedPathname(route) ?? route.route
359
+ return target === '/' ? 'index.html' : `${target.replace(/^\/+/, '')}/index.html`
360
+ }
361
+
362
+ // A param route prerenders one file per param set, never `posts/:slug/index.html`.
363
+ // Report the first as the representative page instead of dropping HTML entirely.
364
+ function prerenderedPathname(route: RouteManifestEntry) {
365
+ const params = route.prerenderedParams?.[0]
366
+ if (!params) return undefined
367
+ return route.route.replace(/:([a-zA-Z0-9_]+)\*?/g, (_match, name: string) => {
368
+ const value = params[name]
369
+ return Array.isArray(value) ? value.join('/') : (value ?? '')
370
+ })
311
371
  }
312
372
 
313
373
  function analyzeTarget(outPath: string) {
374
+ // PPR shells live in `.pnext/ppr`, outside `public`: they are resumed
375
+ // server-side and never downloaded, so they are not shipped weight.
314
376
  const publicPath = path.join(outPath, 'public')
315
- if (existsSync(publicPath)) return { mode: 'production' as const, root: publicPath }
316
-
317
- const cachePath = path.join(outPath, 'cache')
318
- if (existsSync(cachePath)) return { mode: 'development' as const, root: cachePath }
319
-
320
- return null
377
+ return existsSync(publicPath) ? { root: publicPath } : null
321
378
  }
322
379
 
323
380
  function compressedSize(bytes: Buffer, compression: AnalyzeCompression) {
@@ -344,9 +401,7 @@ export function printAnalyzeResult(
344
401
  result: AnalyzeResult,
345
402
  options: { route?: string; files: boolean },
346
403
  ) {
347
- console.log(
348
- `PNext analyze: ${result.mode === 'production' ? 'production build' : 'dev server cache'} at ${dim(result.root)} (${result.compression})`,
349
- )
404
+ console.log(`PNext analyze: production build at ${dim(result.root)} (${result.compression})`)
350
405
  printRouteBundles(result)
351
406
  // With a route filter, the remaining files are mostly other routes' bundles —
352
407
  // listing them as "other" would misread as unowned weight.
@@ -385,11 +440,11 @@ function printDynamicBundleGroup(
385
440
  if (files.length === 0) return
386
441
  console.log(`\n ${bold(label)}`)
387
442
  const columns = rowColumns(files)
388
- const componentWidth = Math.max(...files.map(file => file.exportName.length))
443
+ const componentWidth = Math.max(...files.map(file => file.component.length))
389
444
  const pathWidth = Math.max(...files.map(file => file.path.length))
390
445
  for (const file of files) {
391
446
  console.log(
392
- ` ${file.exportName.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
447
+ ` ${file.component.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
393
448
  )
394
449
  }
395
450
  }
package/src/cli/dev.ts CHANGED
@@ -86,7 +86,11 @@ function watchServerMemory(server: DevServerHandle) {
86
86
  if (!Number.isFinite(limitMb) || limitMb <= 0) return
87
87
  const limitBytes = limitMb * 1024 * 1024
88
88
  const timer = setInterval(() => {
89
- const rss = process.memoryUsage.rss()
89
+ // Bun (<=1.3.x) lacks the `process.memoryUsage.rss` fast path Node provides.
90
+ const rss =
91
+ typeof process.memoryUsage.rss === 'function'
92
+ ? process.memoryUsage.rss()
93
+ : process.memoryUsage().rss
90
94
  if (rss < limitBytes) return
91
95
  clearInterval(timer)
92
96
  const usedMb = Math.round(rss / (1024 * 1024))
@@ -35,8 +35,6 @@ import {
35
35
  } from '../resolve/imports'
36
36
  import {
37
37
  CLIENT_RUNTIME_MODULE,
38
- DYN_SHARED_GLOBAL,
39
- dynSharedSpecifiers,
40
38
  clientEntrySource,
41
39
  clientRuntimeFacts,
42
40
  clientRuntimeSource,
@@ -369,23 +367,60 @@ async function preparePrebuilt(
369
367
  }
370
368
  }
371
369
 
372
- /** The served URL of a deferred dynamic reference's on-demand chunk (dev split). */
373
- export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>) {
374
- return `/__pnext/client-dyn/${reference.id}.js`
370
+ /**
371
+ * The served URL of a deferred dynamic reference's on-demand output (dev split).
372
+ * `r` names the route whose build emitted it: two routes reaching the same island
373
+ * each bundle their own, and one route's copy chunk-splits against its own entry.
374
+ */
375
+ export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>, routeId?: string) {
376
+ const query = routeId ? `?r=${encodeURIComponent(routeId)}` : ''
377
+ return `/__pnext/client-dyn/${reference.id}.js${query}`
378
+ }
379
+
380
+ /** Where the dev server serves this build's chunks from (see publicPath). */
381
+ export const devClientPublicPath = '/__pnext/client'
382
+
383
+ export interface DeferredDynamicEntry {
384
+ id: string
385
+ file: string
386
+ }
387
+
388
+ /**
389
+ * Every deferred reference this route's build must emit an output for: the ones
390
+ * the route scan named, plus the ones its own pipeline rewrite registered.
391
+ */
392
+ function deferredDynamicEntries(route: RouteManifestEntry): DeferredDynamicEntry[] {
393
+ const entries = new Map<string, string>()
394
+ for (const reference of route.clientReferences) {
395
+ if (reference.dynamic && !ssrClientReference(reference)) {
396
+ entries.set(reference.id, reference.file)
397
+ }
398
+ }
399
+ for (const [id, ref] of deferredDynamicRouteRefs.get(route.id) ?? []) entries.set(id, ref.file)
400
+ return [...entries].map(([id, file]) => ({ id, file }))
375
401
  }
376
402
 
377
403
  export interface DeferredDynamicRef {
378
404
  file: string
379
405
  exportName: string
406
+ /** Entry that rewrote this reference: the build its output is emitted by. */
407
+ routeId?: string
380
408
  }
381
409
 
382
410
  // Process-level registry the dev chunk endpoint resolves ids through. Entries
383
411
  // come from the pipeline rewrite below and from each out-dir's sidecar (a
384
412
  // restart serving a cached entry never re-ran the rewrite).
385
413
  const deferredDynamicRefs = new Map<string, DeferredDynamicRef>()
414
+ // Indexed by route as well: a route's build needs every reference it reached as
415
+ // an entry point, and a rewrite only names them once the build has run.
416
+ const deferredDynamicRouteRefs = new Map<string, Map<string, DeferredDynamicRef>>()
386
417
 
387
418
  export function registerDeferredDynamicRef(id: string, ref: DeferredDynamicRef) {
388
419
  deferredDynamicRefs.set(id, ref)
420
+ if (!ref.routeId) return
421
+ const refs = deferredDynamicRouteRefs.get(ref.routeId) ?? new Map<string, DeferredDynamicRef>()
422
+ deferredDynamicRouteRefs.set(ref.routeId, refs)
423
+ refs.set(id, ref)
389
424
  }
390
425
 
391
426
  export function deferredDynamicRefById(id: string) {
@@ -396,11 +431,11 @@ export function deferredDynamicRefById(id: string) {
396
431
  export const DEFERRED_DYNAMIC_SIDECAR = 'dyn-refs.json'
397
432
 
398
433
  /** Dev-only: deferred dynamic references load from the on-demand chunk endpoint. */
399
- function devDeferredDynamicHref(dev: boolean | undefined) {
434
+ function devDeferredDynamicHref(dev: boolean | undefined, routeId?: string) {
400
435
  if (!dev || !devDynamicSplitEnabled()) return undefined
401
436
  return (reference: ClientReference) =>
402
437
  reference.dynamic && !ssrClientReference(reference)
403
- ? deferredDynamicChunkHref(reference)
438
+ ? deferredDynamicChunkHref(reference, routeId)
404
439
  : undefined
405
440
  }
406
441
 
@@ -422,7 +457,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
422
457
  await ensureDir(outDir)
423
458
  const suspense = routeSuspenseFree(config, route) === false
424
459
  const source = clientEntrySource({
425
- deferredDynamicHref: devDeferredDynamicHref(dev),
460
+ deferredDynamicHref: devDeferredDynamicHref(dev, route.id),
426
461
  pageFile: route.client ? route.file : undefined,
427
462
  clientReferences: route.clientReferences,
428
463
  nextCompat: nextCompatEnabled(config),
@@ -439,7 +474,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
439
474
  })
440
475
  const entryName = clientEntryName(route)
441
476
  const outfile = path.join(outDir, `${entryName}.js`)
442
- const pipeline = createClientSourcePipeline(config, dev === true)
477
+ const pipeline = createClientSourcePipeline(config, dev === true, route.id)
443
478
  pipeline.warmRoutes([route])
444
479
  const runtimeFacts = clientRuntimeFacts(
445
480
  [
@@ -467,38 +502,59 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
467
502
  ),
468
503
  )
469
504
 
505
+ const split = Boolean(devDeferredDynamicHref(dev))
506
+ const runEntryBuild = (dynEntries: DeferredDynamicEntry[]) =>
507
+ build({
508
+ ...baseClientBuildOptions(config, dev),
509
+ // Every deferred reference is an entry point of THIS build, so esbuild
510
+ // hoists what it shares with the route entry into a shared chunk. Module
511
+ // identity — contexts, singletons, the preact instance — then holds by
512
+ // construction, and esbuild owns the interop it always did.
513
+ entryPoints: [
514
+ { in: virtualEntryPath(route.id), out: entryName },
515
+ ...dynEntries.map(entry => ({ in: entry.file, out: entry.id })),
516
+ ],
517
+ // Chunk imports as absolute URLs: an on-demand output is served from a
518
+ // different directory than the entry, and a relative specifier would make
519
+ // the browser fetch the same chunk under two URLs — two module instances.
520
+ ...(split ? { publicPath: devClientPublicPath } : {}),
521
+ outdir: outDir,
522
+ metafile: true,
523
+ plugins: clientBuildPlugins(
524
+ config,
525
+ pipeline,
526
+ [
527
+ ...(prebuilt ? [prebuilt.plugin] : []),
528
+ virtualEntryPlugin([{ route, source }]),
529
+ ...(split ? [deferredDynamicExternalPlugin()] : []),
530
+ clientRuntimePlugin(runtimeFacts),
531
+ ],
532
+ !suspense,
533
+ ),
534
+ })
535
+
470
536
  let metafile: Metafile | undefined
537
+ let dynEntries = split ? deferredDynamicEntries(route) : []
471
538
  try {
472
539
  const result = await profileClientBuild(route, dev, () =>
473
- clientProfile.timeAsync('esbuild', () =>
474
- build({
475
- ...baseClientBuildOptions(config, dev),
476
- stdin: {
477
- contents: source,
478
- loader: 'ts',
479
- resolveDir: process.cwd(),
480
- sourcefile: `${route.id}.ts`,
481
- },
482
- outdir: outDir,
483
- entryNames: entryName,
484
- metafile: true,
485
- plugins: clientBuildPlugins(
486
- config,
487
- pipeline,
488
- [
489
- ...(prebuilt ? [prebuilt.plugin] : []),
490
- ...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
491
- clientRuntimePlugin(runtimeFacts),
492
- ],
493
- !suspense,
494
- ),
495
- }),
496
- ),
540
+ clientProfile.timeAsync('esbuild', () => runEntryBuild(dynEntries)),
497
541
  )
498
542
  metafile = result.metafile
499
543
  } catch (error) {
500
544
  throw withClientImportTrace(error, config, route, source)
501
545
  }
546
+ // A dynamic() inside a 'use client' module is only named by the pipeline's
547
+ // rewrite, which runs mid-build: those references become entry points on the
548
+ // rebuild here, and stay ones from then on (the registry outlives the build).
549
+ if (split) {
550
+ const discovered = deferredDynamicEntries(route)
551
+ if (discovered.length !== dynEntries.length) {
552
+ dynEntries = discovered
553
+ metafile = (
554
+ await clientProfile.timeAsync('esbuildDynEntries', () => runEntryBuild(dynEntries))
555
+ ).metafile
556
+ }
557
+ }
502
558
  await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve())
503
559
 
504
560
  if (metafile) {
@@ -522,88 +578,6 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
522
578
  return outfile
523
579
  }
524
580
 
525
- /**
526
- * Dev split: bundle ONE deferred dynamic reference on browser demand. The
527
- * chunk is its own esbuild build, so single-instance vendors (preact and its
528
- * facades) resolve to shared shims reading the entry's published namespaces
529
- * (see dynSharedTableSource) instead of bundling a second copy whose hooks
530
- * would never see the entry renderer's dispatch.
531
- */
532
- export async function buildClientDynamicChunk({
533
- config,
534
- route,
535
- reference,
536
- outDir,
537
- }: {
538
- config: ResolvedConfig
539
- route?: RouteManifestEntry
540
- reference: DeferredDynamicRef & { id: string }
541
- outDir: string
542
- }) {
543
- await ensureDir(outDir)
544
- const pipeline = createClientSourcePipeline(config, true)
545
- const prebuilt = await preparePrebuilt(
546
- config,
547
- outDir,
548
- true,
549
- importer => pipeline.sourceOf(importer) ?? readTextSyncSafe(importer),
550
- route ? surfaceSignature(config, route, true) : '',
551
- )
552
- const source = [
553
- reference.exportName === 'default'
554
- ? `export { default } from ${JSON.stringify(reference.file)};`
555
- : '',
556
- `export * from ${JSON.stringify(reference.file)};`,
557
- ].join('\n')
558
- const outfile = path.join(outDir, `${reference.id}.js`)
559
- await clientProfile.timeAsync('dynChunk', () =>
560
- build({
561
- ...baseClientBuildOptions(config, true),
562
- stdin: {
563
- contents: source,
564
- loader: 'ts',
565
- resolveDir: process.cwd(),
566
- sourcefile: `${reference.id}.dyn.ts`,
567
- },
568
- outdir: outDir,
569
- entryNames: reference.id,
570
- plugins: clientBuildPlugins(config, pipeline, [
571
- ...(prebuilt ? [prebuilt.plugin] : []),
572
- dynSharedVendorPlugin(nextCompatEnabled(config)),
573
- deferredDynamicExternalPlugin(),
574
- ]),
575
- }),
576
- )
577
- await prebuilt?.settle()
578
- clientProfile.report(`client dynamic chunk ${reference.id}`)
579
- return outfile
580
- }
581
-
582
- /** Shared-vendor shims: preact resolves to the entry's published namespace. */
583
- function dynSharedVendorPlugin(nextCompat: boolean): Plugin {
584
- const namespace = 'pnext-dyn-shared'
585
- const filter = new RegExp(`^(?:${dynSharedSpecifiers(nextCompat).map(escapeRegex).join('|')})$`)
586
- return {
587
- name: 'pnext-dyn-shared-vendor',
588
- setup(build) {
589
- build.onResolve({ filter }, args =>
590
- args.namespace === namespace ? undefined : { path: args.path, namespace },
591
- )
592
- build.onLoad({ filter: /.*/, namespace }, async args => {
593
- const names = Object.keys((await import(args.path)) as Record<string, unknown>).filter(
594
- name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default',
595
- )
596
- const lines = [
597
- `const m = window.${DYN_SHARED_GLOBAL}[${JSON.stringify(args.path)}];`,
598
- 'export default (m && m.default);',
599
- ...names.map(name => `export const ${name} = m.${name};`),
600
- ]
601
- return { contents: lines.join('\n'), loader: 'js' }
602
- })
603
- },
604
- }
605
- }
606
-
607
581
  /**
608
582
  * Bundle every route's client entry in a single esbuild build. With `splitting`
609
583
  * enabled, esbuild emits the shared dependency graph (preact runtime, ui kit,
@@ -1404,7 +1378,7 @@ function coreStaticAssetModule(
1404
1378
  * App-convention .js/.mjs may contain JSX (jsxImportSource is preact), so those parse with the jsx
1405
1379
  * loader; scoped to the source roots, so third-party node_modules .js keeps esbuild's default loader.
1406
1380
  */
1407
- function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
1381
+ function createClientSourcePipeline(config: ResolvedConfig, dev = false, routeId?: string) {
1408
1382
  const sourceRewrite = nextCompatEnabled(config)
1409
1383
  const compiler = getCompatModeExtensions().reactCompilerOptions(config)
1410
1384
  const asyncPre = hasClientSourceAsyncPreTransforms()
@@ -1466,9 +1440,10 @@ function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
1466
1440
  specifier => resolveImport(rootFromFile(resolved), resolved, specifier),
1467
1441
  target => {
1468
1442
  const id = clientReferenceId(target.file, target.exportName)
1469
- registerDeferredDynamicRef(id, target)
1470
- deferredRefs.set(id, target)
1471
- return deferredDynamicChunkHref({ id })
1443
+ const ref = { ...target, routeId }
1444
+ registerDeferredDynamicRef(id, ref)
1445
+ deferredRefs.set(id, ref)
1446
+ return deferredDynamicChunkHref({ id }, routeId)
1472
1447
  },
1473
1448
  )
1474
1449
  }