@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.
@@ -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
  }
@@ -91,7 +92,9 @@ function islandContextModulePath() {
91
92
  // Wire-marker revival shared with the server encoder (utils/serialize.ts):
92
93
  // island props carrying a CYCLE travel as `$$pnext_ref` back-references.
93
94
  function staticSlotsModulePath() {
94
- return path.join(import.meta.dirname, '../render/static-slots.ts')
95
+ // The preact-free half: entries import it eagerly, so it must not drag preact into a
96
+ // visible-dynamic entry's static graph.
97
+ return path.join(import.meta.dirname, '../render/static-slots-revive.ts')
95
98
  }
96
99
 
97
100
  function serializeModulePath() {
@@ -291,15 +294,17 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
291
294
  function propsParserSource(nextCompat?: boolean) {
292
295
  const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
293
296
  return `
294
- import { ${revive} as __pnextReviveSerializedRefs } from ${JSON.stringify(serializeModulePath())};
297
+ import { ${revive} as __pnextReviveSerializedRefs, hasPromiseProps as __pnextHasPromiseProps, revivePromiseMarkers as __pnextRevivePromiseMarkers } from ${JSON.stringify(serializeModulePath())};
295
298
  import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots as __pnextReviveIslandSlots } from ${JSON.stringify(staticSlotsModulePath())};
296
299
  // Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
297
300
  // inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
298
301
  // with no element props skip the walk on a substring test of the raw attribute.
299
- function islandProps(raw, root, toChildren) {
302
+ // \`h\` is threaded in rather than imported by the slot reviver: that would put preact in the static
303
+ // graph of entries (visible-dynamic) that otherwise only lazy-import it.
304
+ function islandProps(raw, root, toChildren, h) {
300
305
  const props = parseIslandProps(raw);
301
306
  if (!__pnextHasIslandSlots(raw)) return props;
302
- return __pnextReviveIslandSlots(props, root, toChildren);
307
+ return __pnextReviveIslandSlots(props, root, toChildren, h);
303
308
  }
304
309
  function parseIslandProps(raw) {
305
310
  const props = JSON.parse(raw || '{}');${
@@ -313,25 +318,72 @@ function parseIslandProps(raw) {
313
318
  // rebuilds plain objects, so resolving first would restore identity onto
314
319
  // objects the revival then replaces).
315
320
  const revived = __pnextReviveSerializedRefs(${nextCompat ? 'actions ? actions.reviveProps(props) : props' : 'props'});
316
- // Promise props (page params/searchParams of client slot pages) travel as
317
- // markers; revive them into PRE-FULFILLED promises (React use() protocol:
318
- // status/value readable synchronously). A bare Promise.resolve would make
319
- // use() suspend during hydration, and a suspended hydration re-render
320
- // appends fragment siblings instead of reusing the server DOM.
321
- for (const key of Object.keys(revived)) {
322
- const value = revived[key];
323
- if (value && typeof value === 'object' && '__pnextPromise' in value) {
324
- 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();
325
357
  }
326
358
  }
327
- return revived;
328
- }
329
- function pnextFulfilled(value) {
330
- const promise = Promise.resolve(value);
331
- promise.status = 'fulfilled';
332
- promise.value = value;
333
- 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
+ }
334
385
  }
386
+ pnextInstallHostMeasure();
335
387
  `
336
388
  }
337
389
 
@@ -608,41 +660,6 @@ export interface ClientRuntimeFacts {
608
660
  /** Module specifier the generated route stubs import their runtime from. */
609
661
  export const CLIENT_RUNTIME_MODULE = 'pnext-client-runtime'
610
662
 
611
- /** Dev split: single-instance vendor modules the entry shares with on-demand chunks. */
612
- export const DYN_SHARED_GLOBAL = '__PNEXT_DYN_SHARED__'
613
- export function dynSharedSpecifiers(nextCompat?: boolean) {
614
- return nextCompat
615
- ? [
616
- 'preact',
617
- 'preact/hooks',
618
- 'preact/compat',
619
- 'preact/compat/client',
620
- 'preact/jsx-runtime',
621
- 'preact/jsx-dev-runtime',
622
- ]
623
- : ['preact', 'preact/hooks', 'preact/jsx-runtime']
624
- }
625
-
626
- // A separately-built dynamic chunk must render with THIS entry's preact (hooks
627
- // dispatch through the renderer's own `options`), so the entry publishes its
628
- // vendor namespaces for the chunk build's shared-vendor shims to read. Emitted
629
- // whenever the dev split is armed: dynamic() calls inside 'use client' modules
630
- // are rewritten mid-build (pipeline), after this source is generated.
631
- function dynSharedTableSource(
632
- nextCompat?: boolean,
633
- deferredDynamicHref?: (reference: ClientReference) => string | undefined,
634
- ) {
635
- if (!deferredDynamicHref) return ''
636
- const specifiers = dynSharedSpecifiers(nextCompat)
637
- const imports = specifiers.map(
638
- (specifier, index) => `import * as __pnextDynShared${index} from ${JSON.stringify(specifier)};`,
639
- )
640
- const entries = specifiers.map(
641
- (specifier, index) => `${JSON.stringify(specifier)}: __pnextDynShared${index}`,
642
- )
643
- return `${imports.join('\n')}\nwindow.${DYN_SHARED_GLOBAL} = { ${entries.join(', ')} };\n`
644
- }
645
-
646
663
  export function clientRuntimeFacts(
647
664
  routes: { route: RouteFacts; shell?: boolean }[],
648
665
  nextCompat?: boolean,
@@ -685,6 +702,7 @@ ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
685
702
  ${revival ? propsParserSource(nextCompat) : ''}
686
703
  ${revival ? islandHelpersSource(facts) : ''}
687
704
  ${islandCssHelperSource()}
705
+ ${revival ? hostMeasureSource() : ''}
688
706
  ${mountLifecycleSource(nextCompat)}
689
707
 
690
708
  export function bootstrapRoute(config) {
@@ -785,7 +803,6 @@ export function clientEntrySource(options: ClientEntryOptions) {
785
803
  import { bootstrapRoute${needsCss ? ', loadIslandCss' : ''} } from ${JSON.stringify(CLIENT_RUNTIME_MODULE)};
786
804
  ${pageFile ? `import Page from ${JSON.stringify(pageFile)};` : ''}
787
805
  ${imports}
788
- ${dynSharedTableSource(nextCompat, options.deferredDynamicHref)}
789
806
  ${routerImportSource(facts)}
790
807
  ${boundaryImportSource(nextCompat, hasErrorBoundary(facts), !notFoundFile)}
791
808
 
@@ -887,11 +904,11 @@ function visibleDynamicIslandEntrySource(
887
904
 
888
905
  return `
889
906
  ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
890
- ${dynSharedTableSource(nextCompat, deferredDynamicHref)}
891
907
  ${islandCssHelperSource()}
892
908
  ${routerImportSource(facts)}
893
909
  ${boundaryImportSource(nextCompat, boundary, boundary && !notFoundFile)}
894
910
  ${layoutSegmentHelperSource(nextCompat)}
911
+ ${hostMeasureSource()}
895
912
  ${mountLifecycleSource(nextCompat)}
896
913
  const islands = [
897
914
  ${islandManifest}
@@ -957,7 +974,9 @@ function observeVisibleIsland(root, island) {
957
974
  }
958
975
 
959
976
  function visibleTarget(root) {
960
- 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);
961
980
  if (rect.width || rect.height) return root;
962
981
  return root.parentElement ?? root;
963
982
  }
@@ -978,7 +997,7 @@ async function mountIslandTree(root, island) {
978
997
  ]);
979
998
  ${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
980
999
  const source = preservedSource(root, render);
981
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
1000
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
982
1001
  const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
983
1002
  if (source !== root) adoptPreserved(render, root, wrapped);
984
1003
  else mount(hydrate, render, root, wrapped);
@@ -991,7 +1010,7 @@ async function mountIslandTree(root, island) {
991
1010
  const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), island.load()]);
992
1011
  const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
993
1012
  const source = preservedSource(root, render);
994
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
1013
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
995
1014
  if (source !== root) adoptPreserved(render, root, vnode);
996
1015
  else mount(hydrate, render, root, vnode);
997
1016
  }`
@@ -1077,7 +1096,7 @@ async function domNode(h, node) {
1077
1096
  if (!island) return h(element.localName, domProps(element), await domChildren(h, element));
1078
1097
  const Component = island.Component ?? await island.load();
1079
1098
  const rawProps = element.getAttribute('data-pnext-props') ?? '{}';
1080
- const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node)), await staticChildren(h, element, island.id));
1099
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node), h), await staticChildren(h, element, island.id));
1081
1100
  return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
1082
1101
  }
1083
1102
 
@@ -1364,7 +1383,7 @@ async function mountIslandTree(root, island) {
1364
1383
  // Preserved across a soft navigation: re-render in place with the incoming
1365
1384
  // document's props/children so component state survives while the routed
1366
1385
  // content under the island updates.
1367
- const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
1386
+ const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
1368
1387
  render(${wrapInBoundary('vnode', nextCompat)}, root);
1369
1388
  pnextMountedRoots.add(root);
1370
1389
  return;
@@ -1375,7 +1394,7 @@ async function mountIslandTree(root, island) {
1375
1394
  root.replaceChildren(...incoming.childNodes);
1376
1395
  root.__pnextLive = undefined;
1377
1396
  }
1378
- const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
1397
+ const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
1379
1398
  mount(root, ${wrapInBoundary('vnode', nextCompat)});
1380
1399
  }
1381
1400
 
@@ -1448,7 +1467,7 @@ async function domNode(node) {
1448
1467
  : ''
1449
1468
  }
1450
1469
  const Component = island.Component ?? await island.load();
1451
- return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren)', 'children', 'element', nextCompat), nextCompat)};
1470
+ return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element', nextCompat), nextCompat)};
1452
1471
  }
1453
1472
 
1454
1473
  if (Page && element.id === 'pnext-page') {
@@ -1487,7 +1506,9 @@ ${
1487
1506
  visibleIslands
1488
1507
  ? `
1489
1508
  function visibleTarget(root) {
1490
- 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);
1491
1512
  if (rect.width || rect.height) return root;
1492
1513
  return root.parentElement ?? root;
1493
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>(
@@ -0,0 +1,65 @@
1
+ import type { h, VNode } from 'preact'
2
+
3
+ // Client half of the static-slot protocol, split out of ./static-slots so it stays PREACT-FREE:
4
+ // entries import it eagerly, and a visible-dynamic entry must not pull preact into its static graph
5
+ // (preact declares no `sideEffects: false`, so even an unused binding keeps the chunk import alive).
6
+ // `createElement` is therefore threaded in from the island mount, where preact is lazily imported.
7
+
8
+ export const ISLAND_STATIC_SLOT_ATTRIBUTE = 'data-pnext-static-slot'
9
+ export const ISLAND_STATIC_SLOT_MARKER = '$$pnext_slot'
10
+
11
+ type Props = Record<string, unknown>
12
+
13
+ /** Cheap gate on the raw props attribute so islands with no element props skip the revive walk. */
14
+ export function hasIslandStaticSlots(raw: string) {
15
+ return raw.includes(ISLAND_STATIC_SLOT_MARKER)
16
+ }
17
+
18
+ /**
19
+ * Client mount: swap each `$$pnext_slot` marker for a `pnext-static-slot` host holding the matching
20
+ * server-rendered DOM, converted to vnodes by the entry's DOM walker (so nested islands inside the
21
+ * adopted subtree become real island vnodes and hydrate on their own). The content is static server
22
+ * markup - it never re-renders, same as element children.
23
+ */
24
+ export async function reviveIslandStaticSlots(
25
+ props: Props,
26
+ root: ParentNode,
27
+ toChildren: (node: ParentNode) => unknown,
28
+ createElement: typeof h,
29
+ ): Promise<Props> {
30
+ return (await reviveSlots(props, root, toChildren, createElement, new Set())) as Props
31
+ }
32
+
33
+ async function reviveSlots(
34
+ value: unknown,
35
+ root: ParentNode,
36
+ toChildren: (node: ParentNode) => unknown,
37
+ createElement: typeof h,
38
+ seen: Set<object>,
39
+ ): Promise<unknown> {
40
+ if (value === null || typeof value !== 'object' || seen.has(value)) return value
41
+ const marker = (value as Props)[ISLAND_STATIC_SLOT_MARKER]
42
+ if (typeof marker === 'string') {
43
+ const node = root.querySelector(`[${ISLAND_STATIC_SLOT_ATTRIBUTE}="${cssEscape(marker)}"]`)
44
+ // No server markup for this slot (the island never rendered the prop, or it was skipped for
45
+ // SSR): nothing to adopt, so the prop arrives null rather than as an empty host.
46
+ if (!node) return null
47
+ return createElement(
48
+ 'pnext-static-slot',
49
+ { [ISLAND_STATIC_SLOT_ATTRIBUTE]: marker, style: { display: 'contents' } },
50
+ (await toChildren(node)) as VNode,
51
+ )
52
+ }
53
+ const proto = Object.getPrototypeOf(value) as object | null
54
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
55
+ seen.add(value)
56
+ const target = value as Props
57
+ for (const key of Object.keys(target)) {
58
+ target[key] = await reviveSlots(target[key], root, toChildren, createElement, seen)
59
+ }
60
+ return value
61
+ }
62
+
63
+ function cssEscape(value: string) {
64
+ return value.replace(/["\\]/g, '\\$&')
65
+ }