@wular/pnext 0.0.8 → 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.8",
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": {
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
  }
@@ -56,7 +56,8 @@ interface ClientEntryOptions {
56
56
  shellLayoutOrder?: string[]
57
57
  /**
58
58
  * Dev split: the served URL a deferred dynamic reference loads from instead
59
- * of bundling its target into this entry (see buildClientDynamicChunk).
59
+ * of bundling its target into this entry: the dev split emits that reference as
60
+ * its own entry point of the same build (see buildClientEntry).
60
61
  */
61
62
  deferredDynamicHref?: (reference: ClientReference) => string | undefined
62
63
  }
@@ -293,7 +294,7 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
293
294
  function propsParserSource(nextCompat?: boolean) {
294
295
  const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
295
296
  return `
296
- import { ${revive} as __pnextReviveSerializedRefs } from ${JSON.stringify(serializeModulePath())};
297
+ import { ${revive} as __pnextReviveSerializedRefs, hasPromiseProps as __pnextHasPromiseProps, revivePromiseMarkers as __pnextRevivePromiseMarkers } from ${JSON.stringify(serializeModulePath())};
297
298
  import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots as __pnextReviveIslandSlots } from ${JSON.stringify(staticSlotsModulePath())};
298
299
  // Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
299
300
  // inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
@@ -317,25 +318,72 @@ function parseIslandProps(raw) {
317
318
  // rebuilds plain objects, so resolving first would restore identity onto
318
319
  // objects the revival then replaces).
319
320
  const revived = __pnextReviveSerializedRefs(${nextCompat ? 'actions ? actions.reviveProps(props) : props' : 'props'});
320
- // Promise props (page params/searchParams of client slot pages) travel as
321
- // markers; revive them into PRE-FULFILLED promises (React use() protocol:
322
- // status/value readable synchronously). A bare Promise.resolve would make
323
- // use() suspend during hydration, and a suspended hydration re-render
324
- // appends fragment siblings instead of reusing the server DOM.
325
- for (const key of Object.keys(revived)) {
326
- const value = revived[key];
327
- if (value && typeof value === 'object' && '__pnextPromise' in value) {
328
- revived[key] = pnextFulfilled(value.__pnextPromise);
321
+ // Promise props travel as markers, at the top level (a client slot page's params/searchParams) and
322
+ // nested in plain containers (a dehydrated react-query state's pending-query promise). Revive both
323
+ // into pre-fulfilled promises - see revivePromiseMarkers. The deep walk is gated on a substring
324
+ // test of the raw attribute, so an island with no promise props pays one indexOf.
325
+ return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived) : revived;
326
+ }
327
+ `
328
+ }
329
+
330
+ /** Island hosts pnext emits as `display:contents` custom elements (renderer + compat dynamic). */
331
+ const MEASURED_HOST_TAGS = ['pnext-client', 'pnext-dynamic', 'pnext-static-children']
332
+
333
+ /**
334
+ * A `display:contents` host has no CSS box, so an island child measuring its
335
+ * parentElement (getBoundingClientRect/offsetWidth, or a ResizeObserver on it)
336
+ * sees 0x0 and never resizes — under Next that parent is the app's real
337
+ * container. Upgrading the hosts to custom elements lets their measurement
338
+ * surface delegate to the nearest ancestor that does have a box.
339
+ */
340
+ export function hostMeasureSource() {
341
+ return `
342
+ function pnextInstallHostMeasure() {
343
+ if (typeof customElements === 'undefined' || window.__PNEXT_HOST_MEASURE__) return;
344
+ window.__PNEXT_HOST_MEASURE__ = true;
345
+ const box = el =>
346
+ el.style.display === 'contents' || getComputedStyle(el).display === 'contents'
347
+ ? el.parentElement
348
+ : null;
349
+ class PnextHost extends HTMLElement {
350
+ getBoundingClientRect() {
351
+ const target = box(this);
352
+ return target ? target.getBoundingClientRect() : super.getBoundingClientRect();
353
+ }
354
+ getClientRects() {
355
+ const target = box(this);
356
+ return target ? target.getClientRects() : super.getClientRects();
329
357
  }
330
358
  }
331
- return revived;
332
- }
333
- function pnextFulfilled(value) {
334
- const promise = Promise.resolve(value);
335
- promise.status = 'fulfilled';
336
- promise.value = value;
337
- return promise;
359
+ for (const key of ['clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft']) {
360
+ Object.defineProperty(PnextHost.prototype, key, {
361
+ configurable: true,
362
+ get() {
363
+ const target = box(this);
364
+ return target ? target[key] : 0;
365
+ },
366
+ });
367
+ }
368
+ for (const tag of ${JSON.stringify(MEASURED_HOST_TAGS)}) {
369
+ if (!customElements.get(tag)) customElements.define(tag, class extends PnextHost {});
370
+ }
371
+ // Observing the same element twice is a single native observation, so
372
+ // redirecting host -> parent dedupes on its own.
373
+ const observed = target => (target instanceof PnextHost ? (box(target) ?? target) : target);
374
+ const NativeResizeObserver = window.ResizeObserver;
375
+ if (NativeResizeObserver) {
376
+ window.ResizeObserver = class extends NativeResizeObserver {
377
+ observe(target, options) {
378
+ super.observe(observed(target), options);
379
+ }
380
+ unobserve(target) {
381
+ super.unobserve(observed(target));
382
+ }
383
+ };
384
+ }
338
385
  }
386
+ pnextInstallHostMeasure();
339
387
  `
340
388
  }
341
389
 
@@ -612,41 +660,6 @@ export interface ClientRuntimeFacts {
612
660
  /** Module specifier the generated route stubs import their runtime from. */
613
661
  export const CLIENT_RUNTIME_MODULE = 'pnext-client-runtime'
614
662
 
615
- /** Dev split: single-instance vendor modules the entry shares with on-demand chunks. */
616
- export const DYN_SHARED_GLOBAL = '__PNEXT_DYN_SHARED__'
617
- export function dynSharedSpecifiers(nextCompat?: boolean) {
618
- return nextCompat
619
- ? [
620
- 'preact',
621
- 'preact/hooks',
622
- 'preact/compat',
623
- 'preact/compat/client',
624
- 'preact/jsx-runtime',
625
- 'preact/jsx-dev-runtime',
626
- ]
627
- : ['preact', 'preact/hooks', 'preact/jsx-runtime']
628
- }
629
-
630
- // A separately-built dynamic chunk must render with THIS entry's preact (hooks
631
- // dispatch through the renderer's own `options`), so the entry publishes its
632
- // vendor namespaces for the chunk build's shared-vendor shims to read. Emitted
633
- // whenever the dev split is armed: dynamic() calls inside 'use client' modules
634
- // are rewritten mid-build (pipeline), after this source is generated.
635
- function dynSharedTableSource(
636
- nextCompat?: boolean,
637
- deferredDynamicHref?: (reference: ClientReference) => string | undefined,
638
- ) {
639
- if (!deferredDynamicHref) return ''
640
- const specifiers = dynSharedSpecifiers(nextCompat)
641
- const imports = specifiers.map(
642
- (specifier, index) => `import * as __pnextDynShared${index} from ${JSON.stringify(specifier)};`,
643
- )
644
- const entries = specifiers.map(
645
- (specifier, index) => `${JSON.stringify(specifier)}: __pnextDynShared${index}`,
646
- )
647
- return `${imports.join('\n')}\nwindow.${DYN_SHARED_GLOBAL} = { ${entries.join(', ')} };\n`
648
- }
649
-
650
663
  export function clientRuntimeFacts(
651
664
  routes: { route: RouteFacts; shell?: boolean }[],
652
665
  nextCompat?: boolean,
@@ -689,6 +702,7 @@ ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
689
702
  ${revival ? propsParserSource(nextCompat) : ''}
690
703
  ${revival ? islandHelpersSource(facts) : ''}
691
704
  ${islandCssHelperSource()}
705
+ ${revival ? hostMeasureSource() : ''}
692
706
  ${mountLifecycleSource(nextCompat)}
693
707
 
694
708
  export function bootstrapRoute(config) {
@@ -789,7 +803,6 @@ export function clientEntrySource(options: ClientEntryOptions) {
789
803
  import { bootstrapRoute${needsCss ? ', loadIslandCss' : ''} } from ${JSON.stringify(CLIENT_RUNTIME_MODULE)};
790
804
  ${pageFile ? `import Page from ${JSON.stringify(pageFile)};` : ''}
791
805
  ${imports}
792
- ${dynSharedTableSource(nextCompat, options.deferredDynamicHref)}
793
806
  ${routerImportSource(facts)}
794
807
  ${boundaryImportSource(nextCompat, hasErrorBoundary(facts), !notFoundFile)}
795
808
 
@@ -891,11 +904,11 @@ function visibleDynamicIslandEntrySource(
891
904
 
892
905
  return `
893
906
  ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
894
- ${dynSharedTableSource(nextCompat, deferredDynamicHref)}
895
907
  ${islandCssHelperSource()}
896
908
  ${routerImportSource(facts)}
897
909
  ${boundaryImportSource(nextCompat, boundary, boundary && !notFoundFile)}
898
910
  ${layoutSegmentHelperSource(nextCompat)}
911
+ ${hostMeasureSource()}
899
912
  ${mountLifecycleSource(nextCompat)}
900
913
  const islands = [
901
914
  ${islandManifest}
@@ -961,7 +974,9 @@ function observeVisibleIsland(root, island) {
961
974
  }
962
975
 
963
976
  function visibleTarget(root) {
964
- const rect = root.getBoundingClientRect();
977
+ // The host's OWN box: hostMeasureSource delegates the patched one to the
978
+ // parent, and IntersectionObserver never fires for a display:contents target.
979
+ const rect = Element.prototype.getBoundingClientRect.call(root);
965
980
  if (rect.width || rect.height) return root;
966
981
  return root.parentElement ?? root;
967
982
  }
@@ -1491,7 +1506,9 @@ ${
1491
1506
  visibleIslands
1492
1507
  ? `
1493
1508
  function visibleTarget(root) {
1494
- const rect = root.getBoundingClientRect();
1509
+ // The host's OWN box: hostMeasureSource delegates the patched one to the
1510
+ // parent, and IntersectionObserver never fires for a display:contents target.
1511
+ const rect = Element.prototype.getBoundingClientRect.call(root);
1495
1512
  if (rect.width || rect.height) return root;
1496
1513
  return root.parentElement ?? root;
1497
1514
  }
@@ -746,13 +746,10 @@ async function emitFontBytes(
746
746
  // Next's loader names emitted files `[hash]-s.p.[ext]`: `-s` when a
747
747
  // size-adjust fallback font is used, `.p` when the font is preloaded.
748
748
  const filename = `${hash}${emit.adjustFontFallback ? '-s' : ''}${emit.preload ? '.p' : ''}.${ext}`
749
- // Served from `/_next/static/media/` (files under public/ map 1:1 to the URL
750
- // path). Dev stashes under cache/ but keeps the same relative layout.
749
+ // Served from `/_next/static/media/` files under the out dir's public/ map 1:1 to the URL path,
750
+ // which is the only place dev and build both look them up.
751
751
  const mediaSegments = ['_next', 'static', 'media']
752
- const outRoot = context.dev
753
- ? path.join(context.config.outPath, 'cache')
754
- : path.join(context.config.outPath, 'public')
755
- const outDir = path.join(outRoot, ...mediaSegments)
752
+ const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
756
753
  const file = path.join(outDir, filename)
757
754
  await mkdir(outDir, { recursive: true })
758
755
  if (!existsSync(file)) {
package/src/dev/server.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs'
2
- import { createHash } from 'node:crypto'
3
2
  import { readFile, readdir, rm, stat, watch } from 'node:fs/promises'
4
3
  import path from 'node:path'
5
4
  import {
6
5
  DEFERRED_DYNAMIC_SIDECAR,
7
- buildClientDynamicChunk,
8
6
  buildClientEntry,
9
7
  deferredDynamicRefById,
10
8
  prebuiltRuntimeDir,
@@ -478,14 +476,14 @@ export async function startDevServer(options: DevServerOptions) {
478
476
 
479
477
  const dynChunkMatch = /^\/__pnext\/client-dyn\/([A-Za-z0-9-]+)\.js$/.exec(url.pathname)
480
478
  if (dynChunkMatch?.[1]) {
481
- const found = findDeferredDynamicReference(routes, dynChunkMatch[1])
482
- if (!found)
479
+ const id = dynChunkMatch[1]
480
+ const file = await profileDevStep(profile, `client dyn chunk ${id}`, () =>
481
+ devDynamicEntryFile(config, routes, id, url.searchParams.get('r')),
482
+ )
483
+ if (!file)
483
484
  return finish(
484
485
  applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse),
485
486
  )
486
- const file = await profileDevStep(profile, `client dyn chunk ${dynChunkMatch[1]}`, () =>
487
- buildDevDynamicChunk(config, found.route, found.reference, devImportVersion),
488
- )
489
487
  return finish(
490
488
  applyProxyResponse(
491
489
  devResponse(await readFile(file), 'text/javascript; charset=utf-8'),
@@ -493,15 +491,6 @@ export async function startDevServer(options: DevServerOptions) {
493
491
  ),
494
492
  )
495
493
  }
496
- const dynSharedChunk = /^\/__pnext\/client-dyn\/chunks\/(.+\.js)$/.exec(url.pathname)
497
- if (dynSharedChunk?.[1]) {
498
- const chunkFile = dynChunkFiles.get(dynSharedChunk[1])
499
- if (chunkFile && existsSync(chunkFile))
500
- return finish(
501
- applyProxyResponse(devChunkResponse(await readFile(chunkFile)), proxyResponse),
502
- )
503
- return finish(applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse))
504
- }
505
494
 
506
495
  const clientMatch = /^\/__pnext\/client\/(.+)\.js$/.exec(url.pathname)
507
496
  if (clientMatch?.[1]) {
@@ -1081,48 +1070,32 @@ function findDeferredDynamicReference(routes: RouteManifestEntry[], id: string)
1081
1070
  if (reference) return { route, reference }
1082
1071
  }
1083
1072
  const registered = deferredDynamicRefById(id)
1084
- if (registered) return { route: undefined, reference: { id, ...registered } }
1073
+ if (registered)
1074
+ return {
1075
+ route: routes.find(item => item.id === registered.routeId),
1076
+ reference: { id, ...registered },
1077
+ }
1085
1078
  return undefined
1086
1079
  }
1087
1080
 
1088
- // On-demand dynamic chunk builds: keyed by (id, dev import version) so a save
1089
- // invalidates; chunk files index feeds /__pnext/client-dyn/chunks/ requests.
1090
- const dynChunkBuilds = new Map<string, Promise<string>>()
1091
- const dynChunkFiles = new Map<string, string>()
1092
-
1093
- async function buildDevDynamicChunk(
1081
+ /**
1082
+ * The on-demand output of a deferred reference: an entry point of its route's own
1083
+ * client build, so it is emitted (and chunk-split against that entry) by the build
1084
+ * the route's bundle already runs. `r` names that route; a reference the scan or
1085
+ * the registry can place needs no query.
1086
+ */
1087
+ async function devDynamicEntryFile(
1094
1088
  config: ResolvedConfig,
1095
- route: RouteManifestEntry | undefined,
1096
- reference: { id: string; file: string; exportName: string },
1097
- devImportVersion: string,
1089
+ routes: RouteManifestEntry[],
1090
+ id: string,
1091
+ routeId: string | null,
1098
1092
  ) {
1099
- const key = `${reference.id}\0${devImportVersion}`
1100
- const existing = dynChunkBuilds.get(key)
1101
- if (existing) return existing
1102
- const outDir = path.join(
1103
- config.outPath,
1104
- 'cache',
1105
- 'client-dyn',
1106
- `${reference.id}-${createHash('sha1').update(devImportVersion).digest('hex').slice(0, 8)}`,
1107
- )
1108
- const build = (async () => {
1109
- const outfile = path.join(outDir, `${reference.id}.js`)
1110
- if (!existsSync(outfile)) {
1111
- await buildClientDynamicChunk({ config, route, reference, outDir })
1112
- }
1113
- const chunksDir = path.join(outDir, 'chunks')
1114
- if (existsSync(chunksDir)) {
1115
- for (const entry of await readdir(chunksDir)) {
1116
- if (entry.endsWith('.js')) dynChunkFiles.set(entry, path.join(chunksDir, entry))
1117
- }
1118
- }
1119
- return outfile
1120
- })().catch(error => {
1121
- dynChunkBuilds.delete(key)
1122
- throw error
1123
- })
1124
- dynChunkBuilds.set(key, build)
1125
- return build
1093
+ const scoped = routeId ? routes.find(route => route.id === routeId) : undefined
1094
+ const route = scoped ?? findDeferredDynamicReference(routes, id)?.route
1095
+ if (!route) return undefined
1096
+ const outDir = path.dirname(await buildDevClient(config, route))
1097
+ const file = path.join(outDir, `${id}.js`)
1098
+ return isInside(outDir, file) && existsSync(file) ? file : undefined
1126
1099
  }
1127
1100
 
1128
1101
  /** A cached entry never re-ran the pipeline rewrite: recover its chunk-id map. */
@@ -1130,7 +1103,7 @@ async function loadDeferredDynamicSidecar(outDir: string) {
1130
1103
  try {
1131
1104
  const parsed = JSON.parse(
1132
1105
  await readFile(path.join(outDir, DEFERRED_DYNAMIC_SIDECAR), 'utf8'),
1133
- ) as Record<string, { file: string; exportName: string }>
1106
+ ) as Record<string, { file: string; exportName: string; routeId?: string }>
1134
1107
  for (const [id, ref] of Object.entries(parsed)) {
1135
1108
  if (ref?.file && ref.exportName) registerDeferredDynamicRef(id, ref)
1136
1109
  }
@@ -6140,34 +6140,39 @@ async function serializablePromiseProps(props: ServerVNodeProps): Promise<Server
6140
6140
  next[key] =
6141
6141
  key === 'children'
6142
6142
  ? value
6143
- : isPromise(value)
6143
+ : isPromise(value) && isHangingPromise(value)
6144
6144
  ? // A fallback shell's params/searchParams promise HANGS: awaiting it
6145
6145
  // here rejects with PostponeError outside every <Suspense>, which
6146
6146
  // destroys the whole partial shell (no shell -> no segment
6147
6147
  // artifacts). Emit the placeholder instead; withRequestRouteParams
6148
6148
  // re-stamps it with the serving request's values.
6149
- isHangingPromise(value)
6150
- ? placeholderPromiseMarker(key)
6151
- : promiseMarker(await value)
6149
+ placeholderPromiseMarker(key)
6152
6150
  : await deepResolveNestedPromises(value)
6153
6151
  }
6154
6152
  return next
6155
6153
  }
6156
6154
 
6157
6155
  /**
6158
- * A prop's own top-level value gets the promiseMarker treatment above, preserving pending-promise
6159
- * semantics for use(). A Promise buried inside a nested object/array/Map/Set is not revived as a
6160
- * live promise on the client, so there is no point wire-marking it - just await it in place so the
6161
- * structure serializes instead of crashing the render.
6156
+ * Await every Promise in a prop and replace it with a `promiseMarker`, at the top level (a page's
6157
+ * params) and buried in a nested object/array/Map/Set alike (a react-query dehydrated state keeps
6158
+ * its pending-query promise at `state.queries[n].promise`). The marker is what preserves PROMISE
6159
+ * IDENTITY across the wire: the client revives it into a pre-fulfilled promise, so a consumer that
6160
+ * calls `.then` on it - use(), react-query's tryResolveSync - still finds a thenable.
6161
+ *
6162
+ * A hanging promise (partial prerender) is never awaited - that would wedge the render - and a
6163
+ * nested one has no re-stampable identity, so it settles as null exactly like a non-request-API
6164
+ * hanging prop.
6162
6165
  */
6163
6166
  async function deepResolveNestedPromises(
6164
6167
  value: unknown,
6165
6168
  seen = new Map<object, unknown>(),
6166
6169
  ): Promise<unknown> {
6167
- if (isPromise(value)) return deepResolveNestedPromises(await value, seen)
6170
+ if (isPromise(value)) {
6171
+ if (isHangingPromise(value)) return promiseMarker(null)
6172
+ return promiseMarker(await deepResolveNestedPromises(await value, seen))
6173
+ }
6168
6174
  if (value === null || typeof value !== 'object') return value
6169
- const existing = seen.get(value)
6170
- if (existing !== undefined) return existing
6175
+ if (seen.has(value)) return seen.get(value)
6171
6176
  if (Array.isArray(value)) {
6172
6177
  const items: unknown[] = []
6173
6178
  seen.set(value, items)
@@ -6432,18 +6437,58 @@ export async function renderActionReturnElement(
6432
6437
  return renderVNodeToString(h(Fragment, null, resolved))
6433
6438
  }
6434
6439
 
6440
+ /**
6441
+ * preact's stream renderer builds the SHELL synchronously: a component that suspends with no
6442
+ * <Suspense> registered above it escapes `start()` as this error instead of being awaited. The
6443
+ * non-streaming path has no such limit (renderToStringAsync retries the suspension), so falling
6444
+ * back to it keeps a suspending island - a react-query island reading a dehydrated promise, say -
6445
+ * rendering exactly as it does on a non-streamed request.
6446
+ */
6447
+ const SYNC_SUSPENSE_MESSAGE = 'Use "renderToStringAsync" for suspenseful rendering.'
6448
+
6449
+ function isSyncSuspenseError(error: unknown): boolean {
6450
+ return error instanceof Error && error.message === SYNC_SUSPENSE_MESSAGE
6451
+ }
6452
+
6453
+ /** Sentinel: the shell suspended synchronously and must be re-rendered by the awaited path. */
6454
+ const SHELL_SUSPENDED = Symbol('pnext.shellSuspended')
6455
+
6435
6456
  async function renderClientPageStreamShell(vnode: VNode, state: StreamState): Promise<string> {
6436
- return suspendingStreamScope.run(true, () => renderSuspendingStreamShell(vnode, state))
6457
+ const shell = await suspendingStreamScope.run(true, () =>
6458
+ renderSuspendingStreamShell(vnode, state),
6459
+ )
6460
+ if (shell !== SHELL_SUSPENDED) return shell
6461
+ // Deliberately OUTSIDE the suspending-stream scope: that scope disables preact's error
6462
+ // boundaries, and the awaited path needs them back on to match the non-streamed render exactly.
6463
+ return renderVNodeToString(vnode, state)
6437
6464
  }
6438
6465
 
6439
- async function renderSuspendingStreamShell(vnode: VNode, state: StreamState): Promise<string> {
6466
+ async function renderSuspendingStreamShell(
6467
+ vnode: VNode,
6468
+ state: StreamState,
6469
+ ): Promise<string | typeof SHELL_SUSPENDED> {
6440
6470
  state.clientPageStream = true
6441
- const stream = actionSerializeScope.run(state, () => renderToReadableStream(vnode))
6442
- const reader = stream.getReader()
6443
- let first: Awaited<ReturnType<typeof reader.read>>
6471
+ // The shell renders inside `new ReadableStream`'s start(), so a synchronous suspension throws
6472
+ // out of the CONSTRUCTOR - the read below never happens. Both have to be guarded.
6473
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
6474
+ let first: Awaited<ReturnType<NonNullable<typeof reader>['read']>>
6444
6475
  try {
6476
+ const stream = actionSerializeScope.run(state, () => renderToReadableStream(vnode))
6477
+ // preact hangs a SECOND promise off the stream - `allReady`, resolved when the whole render
6478
+ // (shell + every suspended boundary) lands - and rejects BOTH it and the stream with the same
6479
+ // error. Reading the stream owns one of them; nothing owns `allReady`, so a shell that fails
6480
+ // reports an unhandled rejection on top of the failure the caller already handled. pnext never
6481
+ // awaits full-render completion (the deferred tail below drains the reader instead), so the
6482
+ // handler is a discard rather than a second error path.
6483
+ void (stream as { allReady?: Promise<void> }).allReady?.catch(() => undefined)
6484
+ reader = stream.getReader()
6445
6485
  first = await reader.read()
6446
6486
  } catch (error) {
6487
+ if (isSyncSuspenseError(error)) {
6488
+ state.clientPageStream = false
6489
+ void reader?.cancel().catch(() => undefined)
6490
+ return SHELL_SUSPENDED
6491
+ }
6447
6492
  // A whole-page client route streams WITHOUT the ClientPageSsrBoundary (an error boundary
6448
6493
  // disables preact's stream suspense), so nothing tags a throw escaping this render. It came
6449
6494
  // from the client tree by construction, so tag it here - otherwise global-error would redact
@@ -16,6 +16,7 @@ import {
16
16
  type SegmentMatch,
17
17
  } from '../routing/slots'
18
18
  import { toPosixPath } from '../utils/fs'
19
+ import { PROMISE_MARKER_KEY, revivePromiseMarkers } from '../utils/serialize'
19
20
  import type { PageProps, RouteManifestEntry, RouteParamValue, ServerComponent } from '../types'
20
21
 
21
22
  interface SlotRenderOptions {
@@ -394,8 +395,6 @@ async function renderSlotComponent<Options extends SlotRenderOptions>(
394
395
  return h(Component as ComponentType<PageProps>, props)
395
396
  }
396
397
 
397
- const PROMISE_MARKER_KEY = '__pnextPromise'
398
-
399
398
  export function promiseMarker(value: unknown) {
400
399
  return { [PROMISE_MARKER_KEY]: value ?? null }
401
400
  }
@@ -418,17 +417,12 @@ export function promisePlaceholderJson(kind: 'params' | 'searchParams'): string
418
417
  return `{"${PROMISE_MARKER_KEY}":null,"${PROMISE_PLACEHOLDER_KEY}":"${kind}"}`
419
418
  }
420
419
 
421
- /** Revive promise markers into thenables for the SSR pass of an island. */
420
+ /**
421
+ * Revive promise markers into thenables for the SSR pass of an island - the same walk the client
422
+ * entry runs, so a nested marker is a promise on both sides rather than a bare object on one.
423
+ */
422
424
  export function revivePromiseProps(props: Record<string, unknown>) {
423
- const out: Record<string, unknown> = {}
424
- for (const [key, value] of Object.entries(props)) {
425
- if (value && typeof value === 'object' && PROMISE_MARKER_KEY in value) {
426
- out[key] = Promise.resolve((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
427
- } else {
428
- out[key] = value
429
- }
430
- }
431
- return out
425
+ return revivePromiseMarkers({ ...props })
432
426
  }
433
427
 
434
428
  function slotPageProps<Options extends SlotRenderOptions>(
@@ -19,6 +19,12 @@ const SET_MARKER = '$$pnext_set'
19
19
  // restoring identity. Only true cycles are encoded this way - a value that merely appears twice in different
20
20
  // branches is still expanded, so every acyclic payload is byte-identical to what it was before.
21
21
  const REF_MARKER = '$$pnext_ref'
22
+ // Promise-valued island props ride as `{__pnextPromise: resolved}` so the value keeps its PROMISE
23
+ // IDENTITY across the wire - top level (page params) and nested in plain containers (a react-query
24
+ // dehydrated state carries its pending-query promise inside `state.queries[n].promise`). The render
25
+ // half writes the marker (render/slots promiseMarker); both revive halves live here so the
26
+ // preact-free client entry can import them.
27
+ export const PROMISE_MARKER_KEY = '__pnextPromise'
22
28
 
23
29
  type TypedArray =
24
30
  | Int8Array
@@ -197,6 +203,49 @@ function refTarget(root: unknown, node: Record<string, unknown>): unknown {
197
203
  return current
198
204
  }
199
205
 
206
+ /** Cheap gate: an island whose raw props carry no marker skips the revive walk entirely. */
207
+ export function hasPromiseProps(raw: string) {
208
+ return raw.includes(PROMISE_MARKER_KEY)
209
+ }
210
+
211
+ /**
212
+ * Revive every `promiseMarker` in `props` - top level and nested inside plain objects/arrays - into a
213
+ * PRE-FULFILLED promise (React `use()` protocol: status/value readable synchronously). A bare
214
+ * `Promise.resolve` would make `use()` suspend during hydration, and a suspended hydration re-render
215
+ * appends fragment siblings instead of reusing the server DOM.
216
+ *
217
+ * Nested containers are rewritten in place (the client just parsed them; the server already
218
+ * serialized the wire bytes before this runs), the root is not.
219
+ */
220
+ export function revivePromiseMarkers<T>(props: T): T {
221
+ return reviveMarkers(props, new Set()) as T
222
+ }
223
+
224
+ function reviveMarkers(value: unknown, seen: Set<object>): unknown {
225
+ if (value === null || typeof value !== 'object' || seen.has(value)) return value
226
+ if (PROMISE_MARKER_KEY in value) {
227
+ return fulfilledPromise((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
228
+ }
229
+ seen.add(value)
230
+ if (Array.isArray(value)) {
231
+ for (let index = 0; index < value.length; index++) {
232
+ value[index] = reviveMarkers(value[index], seen)
233
+ }
234
+ return value
235
+ }
236
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value
237
+ const target = value as Record<string, unknown>
238
+ for (const key of Object.keys(target)) target[key] = reviveMarkers(target[key], seen)
239
+ return value
240
+ }
241
+
242
+ function fulfilledPromise(value: unknown) {
243
+ const promise = Promise.resolve(value) as Promise<unknown> & { status: string; value: unknown }
244
+ promise.status = 'fulfilled'
245
+ promise.value = value
246
+ return promise
247
+ }
248
+
200
249
  /** A preact vnode: `h` sets `constructor` to undefined on every one it makes. */
201
250
  export function isElementLike(value: unknown): boolean {
202
251
  if (value === null || typeof value !== 'object') return false
@@ -227,6 +276,14 @@ export function assertSerializable(
227
276
  if (value instanceof Date || value instanceof URL) {
228
277
  throw new Error(`${path} must be a plain JSON value`)
229
278
  }
279
+ // A promise the render half could not await: island props are awaited at the top level and inside
280
+ // plain objects/arrays/Maps/Sets, so anything left here sits behind a class instance (or is a
281
+ // server-action argument, where promises are never allowed). Say so instead of "plain object".
282
+ if (typeof (value as { then?: unknown }).then === 'function') {
283
+ throw new Error(
284
+ `${path} is a Promise: a promise prop must be reachable at the top level of the props or nested in plain objects/arrays/Maps/Sets - one held by a class instance cannot be awaited or serialized, and server actions take no promises at all`,
285
+ )
286
+ }
230
287
  if (typeof value !== 'object') return
231
288
  // A value that contains ITSELF is serializable: encodeBinary writes it as a
232
289
  // `$$pnext_ref` back-reference (React flight does the same), so stop walking
@@ -241,8 +298,8 @@ export function assertSerializable(
241
298
  return
242
299
  }
243
300
 
244
- // The slot walk (render/static-slots) never enters Map/Set, so elements inside them must
245
- // still throw rather than ship mangled.
301
+ // Elements stay illegal inside Map/Set: the slot walk (render/static-slots) does not enter
302
+ // them, so allowing one would silently ship a JSON-mangled vnode instead of a `$$pnext_slot`.
246
303
  if (value instanceof Map) {
247
304
  let index = 0
248
305
  for (const [key, item] of value.entries()) {