@wular/pnext 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
3
- import { copyFile, mkdir, readFile, rename, stat } from 'node:fs/promises'
3
+ import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import {
6
6
  build,
@@ -15,6 +15,7 @@ import {
15
15
  } from 'esbuild'
16
16
  import { foldInitialChunks } from './chunk-fold'
17
17
  import { clientEntryName } from './chunk-name'
18
+ import { assetContentHash } from '../utils/asset-hash'
18
19
  import { clientProfile } from './profile'
19
20
  import { frameworkRuntimeAliasEntries, publicEnvDefines, type ResolvedConfig } from '../config'
20
21
  import { cssModuleClientPlugin } from '../css/build'
@@ -57,6 +58,7 @@ import { shouldReactCompile, transformReactCompiler } from './react-compiler'
57
58
  import { createVerboseLogger, formatDuration } from '../utils/verbose'
58
59
  import { dim as dimText } from '../utils/ansi'
59
60
  import { traceEnabled } from '../utils/trace-flags'
61
+ import { frameworkFingerprint } from '../runtime/fingerprint'
60
62
  import type { RouteManifestEntry } from '../types'
61
63
  import {
62
64
  applyClientSourceAsyncPreTransforms,
@@ -477,16 +479,19 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
477
479
  const outfile = path.join(outDir, `${entryName}.js`)
478
480
  const pipeline = createClientSourcePipeline(config, dev === true, route.id)
479
481
  pipeline.warmRoutes([route])
480
- const runtimeFacts = clientRuntimeFacts(
481
- [
482
- {
483
- route,
484
- shell: hasClientRootLayout(config, route),
485
- },
486
- ],
487
- nextCompatEnabled(config),
488
- suspense,
489
- )
482
+ const runtimeFacts: ClientRuntimeFacts = {
483
+ ...clientRuntimeFacts(
484
+ [
485
+ {
486
+ route,
487
+ shell: hasClientRootLayout(config, route),
488
+ },
489
+ ],
490
+ nextCompatEnabled(config),
491
+ suspense,
492
+ ),
493
+ dev: dev === true,
494
+ }
490
495
  const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
491
496
  preparePrebuilt(
492
497
  config,
@@ -654,9 +659,41 @@ export async function buildClientEntries({
654
659
  }
655
660
  }
656
661
  })
662
+ await clientProfile.timeAsync('fingerprint', () => fingerprintClientEntries(outDir, entries))
657
663
  clientProfile.report('client stage')
658
664
  }
659
665
 
666
+ /**
667
+ * Give every route entry a CONTENT-hashed name, replacing the route-identity hash
668
+ * clientEntryName picks for the build. These are served from /_next/static, which
669
+ * is immutable: a name that outlives its bytes pins a stale runtime in every
670
+ * browser that saw the previous deploy. Two routes whose entries are byte
671
+ * identical collapse onto one file — a URL they can honestly share.
672
+ */
673
+ async function fingerprintClientEntries(outDir: string, entries: ClientEntry[]) {
674
+ await Promise.all(
675
+ entries.map(async entry => {
676
+ const from = path.join(outDir, `${entry.entryName}.js`)
677
+ const bytes = await readFile(from).catch(() => undefined)
678
+ if (!bytes) return
679
+ const name = `pnext-client-${assetContentHash(bytes)}.js`
680
+ entry.route.clientEntry = `assets/${name}`
681
+ const to = path.join(outDir, name)
682
+ if (to === from) return
683
+ const sourceMap = `${from}.map`
684
+ if (existsSync(sourceMap)) {
685
+ // The linked map moves with the entry, so the comment has to move too.
686
+ const source = bytes.toString('utf8').replaceAll(`${entry.entryName}.js.map`, `${name}.map`)
687
+ await writeFile(to, source)
688
+ await rm(from, { force: true })
689
+ await rename(sourceMap, `${to}.map`)
690
+ return
691
+ }
692
+ await rename(from, to)
693
+ }),
694
+ )
695
+ }
696
+
660
697
  interface ClientEntry {
661
698
  route: RouteManifestEntry
662
699
  entryName: string
@@ -1905,12 +1942,15 @@ export interface ClientCacheKeyParts {
1905
1942
  }
1906
1943
 
1907
1944
  /**
1908
- * The half of the key that is not a walked source: the client build pipeline's own sources, the
1909
- * generated entry, and the route's reference list. A handful of small reads, so it is cheap to
1910
- * recompute on every lookup - which is what lets the expensive source walk be validated from a
1911
- * persisted index instead (see dev/restart/client-key.ts).
1945
+ * The half of the key that is not a walked source: pnext's own generation, the generated entry, and
1946
+ * the route's reference list. Cheap to recompute on every lookup - which is what lets the expensive
1947
+ * source walk be validated from a persisted index instead (see dev/restart/client-key.ts).
1948
+ *
1949
+ * The generation, not a list of the pipeline's own files: that list named seven of them and missed
1950
+ * the router runtime the entry pulls in, so editing it left every route's out-dir named for the
1951
+ * bundle it no longer builds.
1912
1952
  */
1913
- export async function clientCacheStaticHash(
1953
+ export function clientCacheStaticHash(
1914
1954
  route: RouteManifestEntry,
1915
1955
  nextCompat?: boolean,
1916
1956
  config?: ResolvedConfig,
@@ -1919,12 +1959,8 @@ export async function clientCacheStaticHash(
1919
1959
  // never reads a memo an earlier call left behind.
1920
1960
  conventionCache.clear()
1921
1961
  const hash = createHash('sha256')
1922
- for (const file of clientBuildPipelineFiles()) {
1923
- hash.update(file)
1924
- hash.update('\0')
1925
- hash.update(await readText(file))
1926
- hash.update('\0')
1927
- }
1962
+ hash.update(frameworkFingerprint())
1963
+ hash.update('\0')
1928
1964
 
1929
1965
  hash.update(
1930
1966
  clientEntrySource({
@@ -1944,6 +1980,11 @@ export async function clientCacheStaticHash(
1944
1980
  }),
1945
1981
  )
1946
1982
  hash.update('\0')
1983
+ // The build is scoped to the route id (virtual entry, source pipeline, surface signature, deferred
1984
+ // dynamic href), so it belongs in the key: without it two routes with the same client closure share
1985
+ // an out-dir, and their entries either overwrite each other or serve one another's bytes.
1986
+ hash.update(route.id)
1987
+ hash.update('\0')
1947
1988
  hash.update(route.client ? route.file : '')
1948
1989
  hash.update(JSON.stringify(route.clientReferences))
1949
1990
  return hash.digest('hex')
@@ -1989,7 +2030,7 @@ export async function clientCacheKeyParts(
1989
2030
  config?: ResolvedConfig,
1990
2031
  staticHash?: string,
1991
2032
  ): Promise<ClientCacheKeyParts> {
1992
- const staticDigest = staticHash ?? (await clientCacheStaticHash(route, nextCompat, config))
2033
+ const staticDigest = staticHash ?? clientCacheStaticHash(route, nextCompat, config)
1993
2034
  const sources = await Promise.all(
1994
2035
  (await clientSourceFiles(route)).map(file => readClientCacheSource(file)),
1995
2036
  )
@@ -2004,18 +2045,6 @@ export async function clientCacheKey(
2004
2045
  return (await clientCacheKeyParts(route, nextCompat, config)).key
2005
2046
  }
2006
2047
 
2007
- function clientBuildPipelineFiles() {
2008
- return [
2009
- path.join(import.meta.dirname, 'entry.ts'),
2010
- path.resolve(import.meta.dirname, '..', 'api', 'router.ts'),
2011
- path.resolve(import.meta.dirname, '..', 'api', 'client-navigation.ts'),
2012
- path.join(import.meta.dirname, 'react-compiler.ts'),
2013
- path.join(import.meta.dirname, 'build.ts'),
2014
- path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts'),
2015
- path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts'),
2016
- ].filter(file => existsSync(file))
2017
- }
2018
-
2019
2048
  async function clientSourceFiles(route: RouteManifestEntry) {
2020
2049
  const entryFiles = [
2021
2050
  ...(route.client ? [route.file] : []),
@@ -139,6 +139,142 @@ function islandVNodeExpr(
139
139
  : `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
140
140
  }
141
141
 
142
+ /**
143
+ * A streamed Suspense hole is the one region a live island stops owning: `$RC` replaces the hole's
144
+ * children wholesale before the graft's materialize pass turns the arriving island markers into
145
+ * hosts. Rendering the hole through this component leaves a seam that pass can re-adopt through -
146
+ * INSIDE the parent island's tree, so the new islands keep the parent's whole context chain. Mounting
147
+ * such a host as its own preact root instead severs it from every provider the parent renders.
148
+ */
149
+ function holeSeamSource() {
150
+ return `
151
+ class PnextHole extends Component {
152
+ componentDidMount() {
153
+ if (this.base) this.base.__pnextHole = this;
154
+ }
155
+ componentWillUnmount() {
156
+ if (this.base && this.base.__pnextHole === this) this.base.__pnextHole = undefined;
157
+ }
158
+ componentWillReceiveProps(next) {
159
+ // A soft navigation re-adopts the parent from the incoming document's DOM;
160
+ // the previous document's grafted content must not outlive its children.
161
+ if (next.children !== this.props.children) this.setState({ adopted: undefined });
162
+ }
163
+ render(props, state) {
164
+ return h('pnext-hole', props.attrs, state.adopted !== undefined ? state.adopted : props.children);
165
+ }
166
+ }
167
+ `
168
+ }
169
+
170
+ /**
171
+ * The late-host half of the seam. `adoptHoleSeam` re-renders one hole through its live parent;
172
+ * `claimNestedIslandRoot` is what the island scan calls instead of mounting a nested host.
173
+ */
174
+ function lateIslandClaimSource() {
175
+ return `
176
+ // The graft ($RC) swapped this hole's DOM wholesale, so re-adopting ALL of it is total: nothing in
177
+ // the hole is still preact-owned. The re-render lands inside the parent island's tree, which is what
178
+ // makes the new islands inherit its context.
179
+ // Detaching an element costs it every piece of state the browser keeps outside the DOM - a lazy
180
+ // image's deferred load observation, an iframe's document, playing media, focus - and re-inserting
181
+ // it does not bring any of that back. moveBefore is the state-preserving move (Chrome 133+); it
182
+ // needs both ends connected and refuses some moves outright, so a plain insert stays the fallback.
183
+ function movePnextNode(parent, node) {
184
+ if (parent.moveBefore && parent.isConnected && node.isConnected) {
185
+ try {
186
+ parent.moveBefore(node, null);
187
+ return;
188
+ } catch (error) {}
189
+ }
190
+ parent.append(node);
191
+ }
192
+
193
+ // A subtree rebuilt from adopted vnodes is made of freshly created elements, and a graft can bring
194
+ // in ones the browser had already deferred. Either way a lazy image can end up in the document with
195
+ // no live intersection observation - no request ever fires, and it stays blank forever. Re-running
196
+ // its source selection re-arms it WITHOUT giving up laziness; one already loading (currentSrc set)
197
+ // or already loaded is left alone, so this never forces a fetch or duplicates one.
198
+ // Drop the capture holder, moving its nodes into \`into\` first when the adoption cannot use them.
199
+ function releaseHoleCapture(hole, into) {
200
+ const captured = hole.__pnextCaptured;
201
+ if (!captured) return;
202
+ hole.__pnextCaptured = undefined;
203
+ if (into) for (const node of [...captured.childNodes]) movePnextNode(into, node);
204
+ captured.remove();
205
+ }
206
+
207
+ function rekickLazyLoadables(root) {
208
+ for (const img of root.querySelectorAll('img[loading=lazy]')) {
209
+ if (img.complete || img.currentSrc) continue;
210
+ for (const name of ['src', 'srcset']) {
211
+ const value = img.getAttribute(name);
212
+ if (value === null) continue;
213
+ img.removeAttribute(name);
214
+ img.setAttribute(name, value);
215
+ }
216
+ }
217
+ }
218
+
219
+ async function adoptHoleSeam(hole) {
220
+ const pending = hole.__pnextAdopting;
221
+ if (pending) return pending;
222
+ const seam = hole.__pnextHole;
223
+ // No seam ever appeared: put the captured nodes back rather than losing them.
224
+ if (!seam) return releaseHoleCapture(hole, hole);
225
+ const adopting = (async () => {
226
+ const captured = hole.__pnextCaptured;
227
+ const children = await domChildren(captured ?? hole);
228
+ // Preact rebuilds the subtree from the adopted vnodes, so the server nodes they were read from
229
+ // would survive as unowned duplicates. Clearing and re-rendering within one task keeps the swap
230
+ // inside a single frame - preact's rerender is a microtask, so no paint sees the empty hole.
231
+ releaseHoleCapture(hole);
232
+ hole.replaceChildren();
233
+ seam.setState({ adopted: children ?? null }, () => rekickLazyLoadables(hole));
234
+ })();
235
+ hole.__pnextAdopting = adopting;
236
+ try {
237
+ await adopting;
238
+ } finally {
239
+ if (hole.__pnextAdopting === adopting) hole.__pnextAdopting = undefined;
240
+ }
241
+ }
242
+
243
+ // A nested host belongs to its parent island's DOM adoption pass, never to a mount of its own. Wait
244
+ // out an adoption already in flight (its walk may still reach this host), and claim only what the
245
+ // parent can no longer see - a host the graft materialized after the parent went live - through the
246
+ // hole seam that owns the grafted DOM. A host under a parent that has not mounted yet stays put.
247
+ async function claimNestedIslandRoot(root, parent) {
248
+ // The innermost hole whose seam is live owns this host. A nearer hole with no seam is raw grafted
249
+ // DOM inside an outer seam's region, so that outer one is the tree to re-adopt through.
250
+ let hole;
251
+ for (let node = root.parentElement; node && node !== parent; node = node.parentElement) {
252
+ if (node.localName !== 'pnext-hole') continue;
253
+ if (node.__pnextHole) { hole = node; break; }
254
+ hole ??= node;
255
+ }
256
+ // The parent is mid-adoption and has no seam here yet: its walk may already have snapshotted this
257
+ // hole's fallback, and its hydration then drops the DOM the graft just landed. Park the grafted
258
+ // nodes synchronously - ahead of that render - and adopt them from the holder once the tree is
259
+ // live. A walk that had NOT reached the hole yet simply adopts an empty one and gets the same
260
+ // content back through the seam. The holder is a hidden ELEMENT rather than a fragment: a
261
+ // disconnected parent is exactly what strips browser state, and moveBefore refuses one.
262
+ if (hole && !hole.__pnextHole && !hole.__pnextCaptured && parent.__pnextMounting) {
263
+ const holder = document.createElement('pnext-hole-capture');
264
+ holder.hidden = true;
265
+ document.body.append(holder);
266
+ hole.__pnextCaptured = holder;
267
+ for (const node of [...hole.childNodes]) movePnextNode(holder, node);
268
+ }
269
+ // A parent whose module failed still settles: the claim has to release its capture rather than
270
+ // leave the grafted nodes parked in a holder nobody will ever adopt from.
271
+ await parent.__pnextMounting?.catch(() => {});
272
+ if (!hole || (!hole.__pnextCaptured && !root.isConnected)) return;
273
+ return adoptHoleSeam(hole);
274
+ }
275
+ `
276
+ }
277
+
142
278
  // Two mount passes can reach the same island root concurrently - mountRoute and the streamed-Suspense
143
279
  // re-scan both walk the document, and their "already live" guard runs BEFORE mountIsland awaits the
144
280
  // component module. Without a synchronous claim both passes mount: the first tree is orphaned over the
@@ -291,6 +427,22 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
291
427
  // The `$$pnextError` marker is written by the renderer's action-prop serializer, which returns props
292
428
  // untouched when next compat is off - so core and react entries import the ref+binary reviver and the
293
429
  // error branch never ships.
430
+ /**
431
+ * Both wire carriers - an island's `data-pnext-props` markers and the client page's
432
+ * `__PNEXT_PROPS__` - must rebuild the same legacy sync surface, so they share one rebuilder. The
433
+ * server stamps the flag only under compat.next.legacyRequestAPIs; a strict app reads params the
434
+ * async way and must not grow a surface its server render never had.
435
+ *
436
+ * Emitted under next compat only - the flag rides compat's document scripts, so a core/react entry
437
+ * could never see it and ships neither the import nor the call.
438
+ */
439
+ function syncPropsHelperSource() {
440
+ return `
441
+ import { rebuildSyncProps as __pnextRebuildSyncProps } from ${JSON.stringify(serializeModulePath())};
442
+ const __pnextSyncProps = () => (typeof window !== 'undefined' && window.__PNEXT_SYNC_PROPS__ ? __pnextRebuildSyncProps : undefined);
443
+ `
444
+ }
445
+
294
446
  function propsParserSource(nextCompat?: boolean) {
295
447
  const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
296
448
  return `
@@ -322,7 +474,7 @@ function parseIslandProps(raw) {
322
474
  // nested in plain containers (a dehydrated react-query state's pending-query promise). Revive both
323
475
  // into pre-fulfilled promises - see revivePromiseMarkers. The deep walk is gated on a substring
324
476
  // test of the raw attribute, so an island with no promise props pays one indexOf.
325
- return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived) : revived;
477
+ return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived${nextCompat ? ', __pnextSyncProps()' : ''}) : revived;
326
478
  }
327
479
  `
328
480
  }
@@ -501,15 +653,21 @@ function keepsMountedRoot(root, keep) {
501
653
  // The per-route half of the lifecycle, closing over the route's own mount
502
654
  // state. mountRoute runs the steps the route actually has: an island scan, the
503
655
  // client-shell hydration, the client page.
504
- function routeLifecycleSource({ nextCompat, islands, page, shell }: ClientRuntimeFacts) {
656
+ function routeLifecycleSource({ nextCompat, islands, page, shell, dev }: ClientRuntimeFacts) {
505
657
  const gate = nextCompat
506
658
  ? ' pnextGate(markRouteHydrated, pnextIslandMounts, pnextVisibleMount);\n'
507
659
  : ''
660
+ const islandMounts = dev
661
+ ? `pnextIslandScan.done.then(() => {
662
+ diagnoseSkippedIslands();
663
+ return pnextIslandScan.mounted;
664
+ })`
665
+ : 'pnextIslandScan.done.then(() => pnextIslandScan.mounted)'
508
666
  const mountBody = [
509
667
  islands
510
668
  ? ` if (islands.length > 0) {
511
669
  pnextIslandScan = scanIslands();
512
- pnextIslandMounts = pnextIslandScan.done.then(() => pnextIslandScan.mounted);
670
+ pnextIslandMounts = ${islandMounts};
513
671
  }`
514
672
  : '',
515
673
  shell ? ' if (shell) pnextVisibleMount = hydrateClientShell();' : '',
@@ -639,6 +797,8 @@ ${overlay}installClientErrors({ errorComponent: ${
639
797
  * ships only when some route in the app can actually use it.
640
798
  */
641
799
  export interface ClientRuntimeFacts {
800
+ /** Development-only diagnostics; omitted entirely from production bundles. */
801
+ dev?: boolean
642
802
  nextCompat?: boolean
643
803
  /** Any route mounts islands (island scan, revival, static children). */
644
804
  islands?: boolean
@@ -694,11 +854,12 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
694
854
  // it revives island DOM exactly like an island mount does.
695
855
  const revival = Boolean(facts.islands || shell)
696
856
  return `
697
- import { h, hydrate, render } from 'preact';
857
+ import { ${revival ? 'Component, ' : ''}h, hydrate, render } from 'preact';
698
858
  ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
699
859
  ${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
700
860
  ${suspense ? "import { Suspense } from 'preact/compat';" : ''}
701
861
  ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
862
+ ${nextCompat && (revival || page) ? syncPropsHelperSource() : ''}
702
863
  ${revival ? propsParserSource(nextCompat) : ''}
703
864
  ${revival ? islandHelpersSource(facts) : ''}
704
865
  ${islandCssHelperSource()}
@@ -918,6 +1079,7 @@ const pnextMountedRoots = new Set();
918
1079
  let pnextIslandMounts;
919
1080
  let pnextRender;
920
1081
 
1082
+ ${nextCompat ? syncPropsHelperSource() : ''}
921
1083
  ${propsParserSource(nextCompat)}
922
1084
  ${compatBoundary}
923
1085
  export function mountRoute() {
@@ -1119,6 +1281,10 @@ function domProps(element) {
1119
1281
  return props;
1120
1282
  }
1121
1283
 
1284
+ // A nested root belongs to its parent island's DOM adoption pass, never to a
1285
+ // mount of its own. This entry carries no streamed-hole seam (see the shared
1286
+ // runtime): its whole point is a preact-free first paint, and a host grafted in
1287
+ // after a visible island mounted would be dead weight in every bundle.
1122
1288
  function isNestedIslandRoot(root) {
1123
1289
  return root.parentElement?.closest('pnext-client') != null;
1124
1290
  }
@@ -1195,16 +1361,21 @@ const rawProps = window.__PNEXT_PROPS__ ?? {};
1195
1361
  // Pre-fulfilled promises (status/value readable synchronously): use() must
1196
1362
  // not suspend during page hydration or preact appends fragment siblings
1197
1363
  // instead of reusing the server DOM.
1198
- const fulfilled = value => {
1364
+ // Under compat.next.legacyRequestAPIs the server also gave these promises the resolved object's
1365
+ // own keys (withSyncProps), so Next-14 code reads \`params.creator\` without await; rebuild that
1366
+ // same surface here or the hydrated page loses every param the SSR render had.
1367
+ ${nextCompat ? 'const legacy = __pnextSyncProps();' : ''}
1368
+ const fulfilled = (kind, value) => {
1199
1369
  const promise = Promise.resolve(value);
1200
1370
  promise.status = 'fulfilled';
1201
1371
  promise.value = value;
1202
- return promise;
1372
+ return ${nextCompat ? 'legacy ? legacy(promise, kind, value) : promise' : 'promise'};
1203
1373
  };
1204
1374
  return {
1205
1375
  ...rawProps,
1206
- params: fulfilled(rawProps.params ?? {}),
1376
+ params: fulfilled('params', rawProps.params ?? {}),
1207
1377
  searchParams: fulfilled(
1378
+ 'searchParams',
1208
1379
  rawProps.searchParams ?? Object.fromEntries(new URLSearchParams(location.search)),
1209
1380
  ),
1210
1381
  };
@@ -1320,9 +1491,39 @@ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
1320
1491
  `
1321
1492
  }
1322
1493
 
1323
- function hydrateIslandsSource({ nextCompat, visibleIslands, deferredIslands }: ClientRuntimeFacts) {
1494
+ function hydrateIslandsSource({
1495
+ nextCompat,
1496
+ visibleIslands,
1497
+ deferredIslands,
1498
+ dev,
1499
+ }: ClientRuntimeFacts) {
1500
+ const diagnostic = dev
1501
+ ? `
1502
+ function diagnoseSkippedIslands() {
1503
+ for (const root of document.querySelectorAll('pnext-client[data-pnext-client]')) {
1504
+ if (root.__pnextLive) continue;
1505
+ const id = root.getAttribute('data-pnext-client');
1506
+ const island = id ? islandById.get(id) : undefined;
1507
+ const parent = nestedIslandParent(root);
1508
+ // A visible island is deliberately inert until intersection. A nested
1509
+ // island whose parent is still pending is likewise owned by that parent's
1510
+ // adoption pass, one parked in a capture holder is mid-adoption, and one the
1511
+ // claim pass adopted is no longer in the document. Everything else is a host
1512
+ // this route can never hydrate.
1513
+ if (island?.options?.load === 'visible') continue;
1514
+ if (root.closest('pnext-hole-capture')) continue;
1515
+ if (parent && !parent.__pnextLive) continue;
1516
+ const reason = island
1517
+ ? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
1518
+ : 'has no entry in the client island manifest';
1519
+ console.warn('pnext: client island ' + (id ?? '<missing id>') + ' ' + reason);
1520
+ }
1521
+ }
1522
+ `
1523
+ : ''
1324
1524
  return `
1325
1525
  const islandById = new Map(islands.map(island => [island.id, island]));
1526
+ ${lateIslandClaimSource()}${diagnostic}
1326
1527
 
1327
1528
  // The mounted count is known as soon as the scan loop finishes, before any
1328
1529
  // island module resolves — so the client shell can decide whether it owns the
@@ -1341,7 +1542,11 @@ for (const island of islands) {
1341
1542
  }
1342
1543
  for (const island of islands) {
1343
1544
  for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
1344
- if (isNestedIslandRoot(root)) continue;
1545
+ const parent = nestedIslandParent(root);
1546
+ if (parent) {
1547
+ pending.push(claimNestedIslandRoot(root, parent));
1548
+ continue;
1549
+ }
1345
1550
  // Already mounted (e.g. a re-scan after a streamed Suspense hole materialized new islands):
1346
1551
  // mount() stamps __pnextLive, so skipping these leaves the shell's live islands untouched. A root
1347
1552
  // preserved across a soft navigation is ALSO live, but carries the incoming placeholder (fresh SSR
@@ -1453,6 +1658,10 @@ async function domNode(node) {
1453
1658
 
1454
1659
  const element = node;
1455
1660
  if (element.localName === ${JSON.stringify(ISLAND_BOUNDARY_ERROR_ELEMENT)}) return pnextBoundaryErrorVNode(element);
1661
+ // The seam a late streamed graft re-adopts through (see holeSeamSource).
1662
+ if (element.localName === 'pnext-hole') {
1663
+ return h(PnextHole, { attrs: domProps(element) }, await domChildren(element));
1664
+ }
1456
1665
  if (element.localName === 'pnext-client') {
1457
1666
  const id = element.getAttribute('data-pnext-client');
1458
1667
  const island = id ? islandById.get(id) : undefined;
@@ -1499,12 +1708,15 @@ function domProps(element) {
1499
1708
  return props;
1500
1709
  }
1501
1710
 
1502
- function isNestedIslandRoot(root) {
1503
- return root.parentElement?.closest('pnext-client') != null;
1711
+ // The island host that owns this root's DOM, if any. A nested root is never
1712
+ // mounted here: the parent adopts it, or claimNestedIslandRoot routes it through
1713
+ // the parent's hole seam.
1714
+ function nestedIslandParent(root) {
1715
+ return root.parentElement?.closest('pnext-client');
1504
1716
  }
1505
- ${
1506
- visibleIslands
1507
- ? `
1717
+ ${holeSeamSource()}${
1718
+ visibleIslands
1719
+ ? `
1508
1720
  function visibleTarget(root) {
1509
1721
  // The host's OWN box: hostMeasureSource delegates the patched one to the
1510
1722
  // parent, and IntersectionObserver never fires for a display:contents target.
@@ -1513,8 +1725,8 @@ function visibleTarget(root) {
1513
1725
  return root.parentElement ?? root;
1514
1726
  }
1515
1727
  `
1516
- : ''
1517
- }${mountOnceSource()}${
1728
+ : ''
1729
+ }${mountOnceSource()}${
1518
1730
  deferredIslands
1519
1731
  ? `
1520
1732
  function DeferredIsland({ load, props, children }) {
@@ -0,0 +1,81 @@
1
+ type PageSlotRange = [start: Element | Comment, end: Comment | undefined, nodes: ChildNode[]]
2
+
3
+ /** Resolve either wire form of a page slot inside a preserved server segment. */
4
+ export function pageSlotRange(segment: Element): PageSlotRange | null {
5
+ const element = segment.querySelector('#pnext-page')
6
+ if (element) return [element, undefined, [...element.childNodes]]
7
+ const walker = document.createTreeWalker(segment, 128 /* NodeFilter.SHOW_COMMENT */)
8
+ while (walker.nextNode()) {
9
+ const start = walker.currentNode as Comment
10
+ if (!start.data.startsWith('pnext-page:')) continue
11
+ const nodes: ChildNode[] = []
12
+ let end = start.nextSibling
13
+ while (end && !(end.nodeType === 8 && end.nodeValue === '/pnext-page')) {
14
+ nodes.push(end)
15
+ end = end.nextSibling
16
+ }
17
+ return end ? [start, end as Comment, nodes] : null
18
+ }
19
+ return null
20
+ }
21
+
22
+ /** Whether an element belongs to the page slot in either DOM form. */
23
+ export function elementInPageSlot(element: Element): boolean {
24
+ return !!(
25
+ element.closest('#pnext-page') ||
26
+ pageSlotRange(document.body)?.[2].some(node => node.contains(element))
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Graft a destination page into a preserved layout without changing the destination's mount shape.
32
+ * Client pages need the real `#pnext-page` element; server/island-only pages need the marker range.
33
+ */
34
+ export function graftPageSlot(liveSegment: Element, incomingSegment: Element): boolean {
35
+ const live = pageSlotRange(liveSegment)
36
+ const incoming = pageSlotRange(incomingSegment)
37
+ if (!live || !incoming) return false
38
+ const nodes = reusePageNodes(live[2], incoming[2])
39
+ for (const stale of live[2]) stale.remove()
40
+ const end = incoming[1]
41
+ if (!end) (incoming[0] as Element).replaceChildren(...nodes)
42
+ else {
43
+ for (const stale of incoming[2]) stale.remove()
44
+ }
45
+ live[0].replaceWith(incoming[0])
46
+ if (end) incoming[0].after(...nodes, end)
47
+ live[1]?.remove()
48
+ return true
49
+ }
50
+
51
+ // Next re-renders a page in place, so unchanged DOM keeps its node identity.
52
+ function reusePageNodes(live: readonly ChildNode[], incoming: readonly Node[]): Node[] {
53
+ const candidates = live.filter(
54
+ (node): node is Element =>
55
+ node instanceof Element &&
56
+ !node.matches('script, pnext-client, #pnext-page') &&
57
+ !node.querySelector('pnext-client, #pnext-page, [data-pnext-preserve]'),
58
+ )
59
+ return incoming.map(node => {
60
+ if (!(node instanceof Element)) return node
61
+ const index = candidates.findIndex(candidate => candidate.outerHTML === node.outerHTML)
62
+ return index === -1 ? node : candidates.splice(index, 1)[0]!
63
+ })
64
+ }
65
+
66
+ export interface LoadingShellTarget {
67
+ container: HTMLElement
68
+ markerRange?: PageSlotRange
69
+ }
70
+
71
+ /** Resolve a safe loading-shell target without falling through a dissolved page to the body. */
72
+ export function loadingShellTarget(): LoadingShellTarget {
73
+ const page = document.getElementById('pnext-page')
74
+ if (page) return { container: page }
75
+ const markerRange = pageSlotRange(document.body)
76
+ if (markerRange?.[1]) {
77
+ return { container: markerRange[0].parentElement!, markerRange }
78
+ }
79
+ const root = document.querySelector<HTMLElement>('[data-pnext-root]')
80
+ return { container: root ?? document.body }
81
+ }