@wular/pnext 0.0.19 → 0.0.20

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 (75) 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.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. package/src/utils/serialize.ts +66 -7
@@ -54,7 +54,16 @@ import {
54
54
  reportRequestError,
55
55
  type SerializedError,
56
56
  } from '../extensions'
57
- import { globalCssHref, registerCssRuntime, routeCssHref, withAssetPrefix } from '../css/build'
57
+ import {
58
+ assetHref,
59
+ emitFontCssStylesheet,
60
+ emittedAssetName,
61
+ globalCssHref,
62
+ registerCssRuntime,
63
+ routeCssAssetNames,
64
+ routeCssHref,
65
+ withAssetPrefix,
66
+ } from '../css/build'
58
67
  import { dynamicReferenceSymbol, type DynamicReference } from '../api/dynamic'
59
68
  import {
60
69
  isForbiddenError,
@@ -92,6 +101,7 @@ import {
92
101
  findConventionFiles,
93
102
  findGlobalError,
94
103
  findLayouts,
104
+ serverActionsUnsupportedMessage,
95
105
  } from '../routing/routes'
96
106
  import { matchSegments, pathnameSegments, slotDirectoriesIn } from '../routing/slots'
97
107
  import { getRequestRuntime } from '../routing/request-environment'
@@ -119,10 +129,12 @@ import {
119
129
  } from '../utils/dev-profile'
120
130
  import {
121
131
  applyStaticMetadata,
132
+ assetLinkGroups,
122
133
  coerceString,
123
134
  headLinks,
124
135
  metadataIconLinks,
125
136
  metadataContext,
137
+ metadataOnlyLinks,
126
138
  metadataTags,
127
139
  mergeMetadataEntries,
128
140
  mergeViewport,
@@ -136,7 +148,15 @@ import {
136
148
  type ViewportExport,
137
149
  type MetadataEntry,
138
150
  } from './metadata'
139
- import { clearResourceHints, takeResourceHints } from './resource-hints'
151
+ import {
152
+ clearRenderBuffer,
153
+ clearResourceHints,
154
+ newRenderBufferFrame,
155
+ RENDER_BUFFER_SCOPE,
156
+ takeRenderBuffer,
157
+ takeResourceHints,
158
+ type RenderBufferFrame,
159
+ } from './resource-hints'
140
160
  import {
141
161
  staticMetadataForPath,
142
162
  staticMetadataForPathFromFiles,
@@ -230,6 +250,9 @@ function runWithFontScope<T>(callback: () => T): T {
230
250
 
231
251
  const rawTextRenderingInstalled = Symbol.for('pnext.raw-text-rendering-installed')
232
252
  const actionSerializeScope = new AsyncLocalStorage<ActionSerializeState>()
253
+ // Owns the per-render buffer frame the compat bundles write into; see render/resource-hints.
254
+ const renderBufferScope = ((globalThis as Record<PropertyKey, unknown>)[RENDER_BUFFER_SCOPE] ??=
255
+ new AsyncLocalStorage<RenderBufferFrame>()) as AsyncLocalStorage<RenderBufferFrame>
233
256
  /** True while a render that SUSPENDS in place is running; see installRawTextRendering. */
234
257
  const suspendingStreamScope = new AsyncLocalStorage<boolean>()
235
258
 
@@ -402,7 +425,6 @@ interface RenderOptions {
402
425
  staticMetadataFiles?: StaticMetadataFile[]
403
426
  staticModuleMetadata?: Record<string, StaticModuleMetadata>
404
427
  staticRouteMetadata?: Record<string, StaticMetadataForPath>
405
- resolveDynamicMetadataRoutes?: boolean
406
428
  status?: number
407
429
  partial?: PartialState
408
430
  /**
@@ -506,6 +528,8 @@ interface PageRender {
506
528
  fontCss: string
507
529
  bodyIconLinks?: string
508
530
  compatBodyScript?: string
531
+ /** Inlined CSS (`<style>`); sits with the stylesheet links, ahead of every head script. */
532
+ headStyles?: string
509
533
  headScripts?: string
510
534
  documentHeadTags?: string
511
535
  metadataUrl?: string
@@ -735,9 +759,10 @@ interface DocumentPage extends MetadataRenderPage {
735
759
  fontCss: string
736
760
  bodyIconLinks?: string
737
761
  bodyMetadata?: string
738
- /** Request-time metadata arrives as a deferred stream chunk, not in the head. */
739
- bodyMetadataDeferred?: boolean
762
+ /** Document metadata lives below the fold (streamed or deferred), so the head must not carry it. */
763
+ metadataInBody?: boolean
740
764
  compatBodyScript?: string
765
+ headStyles?: string
741
766
  headScripts?: string
742
767
  documentHeadTags?: string
743
768
  deferredScripts?: string
@@ -775,29 +800,53 @@ function titleTag(title: unknown): string {
775
800
  return value ? `<title>${escapeHtml(value)}</title>` : ''
776
801
  }
777
802
 
803
+ // Head order, taken from React's Fizz preamble (Next inherits it verbatim): charset, preconnects,
804
+ // the viewport meta, font/high-priority-image preloads, the precedence-ordered stylesheets, the bulk
805
+ // preloads, then the bootstrap scripts. Everything registered AFTER that preamble flushes - late
806
+ // preloads, and every ordinary <meta>/<title>/metadata <link>, which React hoists rather than writes
807
+ // into the preamble - follows in encounter order.
808
+ //
809
+ // This only reorders the asset links; the head-vs-body split is untouched, so
810
+ // metadataOnlyLinks still travel with the metadata and never appear when it streams into the body.
778
811
  function renderDocumentStart(page: DocumentPage) {
779
812
  const rendered = page.shell === 'layout' ? page.body : `<main data-pnext-root>${page.body}</main>`
780
- const slotMode = pageSlotMarkerMode(page, rendered)
813
+ const slotMode = pageSlotMarkerMode(page)
781
814
  const body = unwrapUnusedPageSlot(rendered, slotMode)
782
815
  const viewport = viewportContent(page.viewport)
783
816
  const context = metadataContext(page)
784
817
  const headPage = page.bodyIconLinks
785
818
  ? { ...page, metadata: { ...page.metadata, icons: undefined }, suppressIconLinks: true }
786
819
  : page
820
+ const [connects, preloads, stylesheets, bulkPreloads, lateHints] = assetLinkGroups(headPage)
821
+ // Joined with the empty slots dropped: a head with no fonts/hints/inline CSS used to ship a blank
822
+ // line per unused slot, and the bucket split multiplies them.
823
+ const head = [
824
+ '<meta charSet="utf-8"/>',
825
+ connects,
826
+ `<meta name="viewport" content="${escapeHtml(viewport)}"/>`,
827
+ preloads,
828
+ page.fontCss ? `<style data-pnext-font>${page.fontCss}</style>` : '',
829
+ page.headStyles,
830
+ stylesheets,
831
+ bulkPreloads,
832
+ page.headScripts,
833
+ lateHints,
834
+ page.documentHeadTags,
835
+ page.viewport.colorScheme
836
+ ? `<meta name="color-scheme" content="${escapeHtml(page.viewport.colorScheme)}"/>`
837
+ : '',
838
+ themeColorTags(page.viewport.themeColor),
839
+ page.metadataInBody ? '' : titleTag(page.metadata.title),
840
+ page.metadataInBody ? '' : metadataTags(page.metadata, context),
841
+ page.metadataInBody ? '' : metadataOnlyLinks(headPage, context),
842
+ page.head,
843
+ ]
844
+ .filter(Boolean)
845
+ .join('\n ')
787
846
  // Next emits the doctype and <html> on one line; tests match /^<!DOCTYPE html><html/.
788
847
  const html = `<!DOCTYPE html><html${documentAttrs(page.htmlProps, page.metadata.lang ? { lang: page.metadata.lang } : {})}>
789
848
  <head>
790
- <meta charSet="utf-8"/>
791
- <meta name="viewport" content="${escapeHtml(viewport)}"/>
792
- ${page.viewport.colorScheme ? `<meta name="color-scheme" content="${escapeHtml(page.viewport.colorScheme)}"/>` : ''}
793
- ${themeColorTags(page.viewport.themeColor)}
794
- ${page.bodyMetadata || page.bodyMetadataDeferred ? '' : titleTag(page.metadata.title)}
795
- ${page.bodyMetadata || page.bodyMetadataDeferred ? '' : metadataTags(page.metadata, context)}
796
- ${page.bodyMetadata || page.bodyMetadataDeferred ? '' : headLinks(headPage, context)}
797
- ${page.headScripts ?? ''}
798
- ${page.documentHeadTags ?? ''}
799
- ${page.fontCss ? `<style data-pnext-font>${page.fontCss}</style>` : ''}
800
- ${page.head}
849
+ ${head}
801
850
  </head>
802
851
  <body${documentAttrs(page.bodyProps)}>
803
852
  ${body}
@@ -944,14 +993,11 @@ interface CollectedHeadScript {
944
993
  }
945
994
 
946
995
  function clearHeadScripts() {
947
- ;(globalThis as { __PNEXT_HEAD_SCRIPTS__?: CollectedHeadScript[] }).__PNEXT_HEAD_SCRIPTS__ = []
996
+ clearRenderBuffer('head')
948
997
  }
949
998
 
950
999
  function takeHeadScripts() {
951
- const holder = globalThis as { __PNEXT_HEAD_SCRIPTS__?: CollectedHeadScript[] }
952
- const scripts = holder.__PNEXT_HEAD_SCRIPTS__ ?? []
953
- holder.__PNEXT_HEAD_SCRIPTS__ = []
954
- return scripts
1000
+ return takeRenderBuffer<CollectedHeadScript>('head')
955
1001
  }
956
1002
 
957
1003
  function renderCollectedHeadScripts() {
@@ -990,16 +1036,11 @@ interface CollectedDeferredScript {
990
1036
  }
991
1037
 
992
1038
  function clearDeferredScripts() {
993
- ;(
994
- globalThis as { __PNEXT_DEFERRED_SCRIPTS__?: CollectedDeferredScript[] }
995
- ).__PNEXT_DEFERRED_SCRIPTS__ = []
1039
+ clearRenderBuffer('deferred')
996
1040
  }
997
1041
 
998
1042
  function takeDeferredScripts(): CollectedDeferredScript[] {
999
- const holder = globalThis as { __PNEXT_DEFERRED_SCRIPTS__?: CollectedDeferredScript[] }
1000
- const scripts = holder.__PNEXT_DEFERRED_SCRIPTS__ ?? []
1001
- holder.__PNEXT_DEFERRED_SCRIPTS__ = []
1002
- return scripts
1043
+ return takeRenderBuffer<CollectedDeferredScript>('deferred')
1003
1044
  }
1004
1045
 
1005
1046
  // A `type="module"` script, deferred like the hydration entry and ordered after it, so any
@@ -1221,18 +1262,29 @@ function createPageProps(options: RenderRequestOptions): PageProps {
1221
1262
  }
1222
1263
  }
1223
1264
  }
1265
+ const params = trackedParams(options)
1266
+ const searchParams = trackedSearchParams(pageSearchParams(childrenSourceSearchParams(options)))
1224
1267
  return {
1225
1268
  // markRuntimeApiOnAwait is the identity outside a runtime-prefetch
1226
1269
  // prerender; inside one, awaiting params/searchParams arms the sync-IO
1227
1270
  // abort exactly as awaiting cookies()/headers() does.
1228
- params: markRuntimeApiOnAwait(settledPromise(trackedParams(options))),
1271
+ params: markRuntimeApiOnAwait(legacySyncProps(settledPromise(params), 'params', params)),
1229
1272
  searchParams: markRuntimeApiOnAwait(
1230
- settledPromise(trackedSearchParams(pageSearchParams(childrenSourceSearchParams(options)))),
1273
+ legacySyncProps(settledPromise(searchParams), 'searchParams', searchParams),
1231
1274
  ),
1232
1275
  request: options.request ? pageRequest(options.request) : undefined,
1233
1276
  }
1234
1277
  }
1235
1278
 
1279
+ /** Next 15's transitional sync surface on params/searchParams; identity unless compat enables it. */
1280
+ function legacySyncProps<T extends object>(
1281
+ promise: Promise<T>,
1282
+ kind: 'params' | 'searchParams',
1283
+ value: T,
1284
+ ): Promise<T> {
1285
+ return getRenderExtensions().legacySyncProps(promise, kind, value)
1286
+ }
1287
+
1236
1288
  /**
1237
1289
  * The render's concrete params, wrapped in the compat vary-params tracking view
1238
1290
  * when one is active (segment-prefetch renders). Outside a tracking scope the
@@ -1569,9 +1621,6 @@ export async function renderPageResponse(options: RenderOptions) {
1569
1621
  headers: {
1570
1622
  'content-type': 'text/html; charset=utf-8',
1571
1623
  ...(truncated ? { 'x-nextjs-postponed': '1' } : {}),
1572
- // Error pages must never outlive the failure that produced them
1573
- // in any cache (browser, prefetch, or intermediary).
1574
- ...((page.status ?? 200) >= 400 ? { 'cache-control': 'no-store' } : {}),
1575
1624
  },
1576
1625
  },
1577
1626
  )
@@ -2544,7 +2593,7 @@ export async function renderGlobalNotFoundResponse(
2544
2593
  )
2545
2594
  response = new Response(pageStream(page), {
2546
2595
  status: 404,
2547
- headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
2596
+ headers: { 'content-type': 'text/html; charset=utf-8' },
2548
2597
  })
2549
2598
  }
2550
2599
  // Next always marks the not-found page noindex.
@@ -2587,7 +2636,7 @@ export async function renderNotFoundForRoute(
2587
2636
  )
2588
2637
  const response = new Response(pageStream(page), {
2589
2638
  status: 404,
2590
- headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
2639
+ headers: { 'content-type': 'text/html; charset=utf-8' },
2591
2640
  })
2592
2641
  const html = await response.text()
2593
2642
  const withRobots = html.includes('name="robots"')
@@ -2926,7 +2975,6 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
2926
2975
  layoutFiles,
2927
2976
  clientComponents,
2928
2977
  clientComponentsPreload,
2929
- resolveDynamicMetadataRoutes: options.resolveDynamicMetadataRoutes,
2930
2978
  },
2931
2979
  loadedPage,
2932
2980
  stream,
@@ -3172,9 +3220,9 @@ function withGlobalErrorReportScript(html: string, error: SerializedError): stri
3172
3220
  }
3173
3221
 
3174
3222
  function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string {
3175
- const asset = `${config.outPath}/public/assets/global-error.css`
3223
+ const asset = `${config.outPath}/public/assets/${emittedAssetName(config, 'global-error.css')}`
3176
3224
  if (!existsSync(asset)) return html
3177
- const link = `<link rel="stylesheet" href="${withAssetPrefix(config, '/assets/global-error.css')}">`
3225
+ const link = `<link rel="stylesheet" href="${assetHref(config, 'global-error.css')}">`
3178
3226
  if (html.includes('</head>')) return html.replace('</head>', `${link}</head>`)
3179
3227
  return html.replace(/<html(?:\s[^>]*)?>/i, tag => `${tag}<head>${link}</head>`)
3180
3228
  }
@@ -3287,7 +3335,16 @@ async function resolveSelectedSlotMetadata(
3287
3335
  return { entries: [], runtime: false }
3288
3336
  }
3289
3337
 
3290
- async function renderTree(
3338
+ /**
3339
+ * One render, one buffer frame: the resource hints, head scripts and deferred scripts this render
3340
+ * collects are its own, so a render running alongside it (a build's next route, a not-found
3341
+ * document, another request) can neither drain them nor add to them.
3342
+ */
3343
+ function renderTree(...args: Parameters<typeof renderTreeInFrame>): Promise<PageRender> {
3344
+ return renderBufferScope.run(newRenderBufferFrame(), () => renderTreeInFrame(...args))
3345
+ }
3346
+
3347
+ async function renderTreeInFrame(
3291
3348
  options: RenderOptions,
3292
3349
  tree: ComponentChildren,
3293
3350
  stream: boolean,
@@ -3488,19 +3545,22 @@ async function renderTree(
3488
3545
  const prebuiltRouteMetadata = hasMetadataExtensions
3489
3546
  ? undefined
3490
3547
  : options.staticRouteMetadata?.[staticRouteMetadataKey(options.url.pathname)]
3548
+ // No prebuilt entry means nobody has resolved app/manifest.ts & friends for this
3549
+ // pathname yet: dev never prebuilds, and the build only prebuilds paths it knows
3550
+ // (so every parameterised route arrives here). Resolving is the only way those
3551
+ // documents carry the same metadata-file links as the paramless ones. An app with
3552
+ // no dynamic metadata files short-circuits inside withDynamicMetadataRoutes.
3491
3553
  const staticMetadata =
3492
3554
  prebuiltRouteMetadata ??
3493
- (hasMetadataExtensions || options.resolveDynamicMetadataRoutes
3494
- ? await profileRenderStep(profile, 'dynamic metadata routes', () =>
3495
- withDynamicMetadataRoutes(
3496
- staticMetadataBase,
3497
- options.config.appPath,
3498
- options.route.file,
3499
- options.url.pathname,
3500
- file => importModule(file, options),
3501
- ),
3502
- )
3503
- : staticMetadataBase)
3555
+ (await profileRenderStep(profile, 'dynamic metadata routes', () =>
3556
+ withDynamicMetadataRoutes(
3557
+ staticMetadataBase,
3558
+ options.config.appPath,
3559
+ options.route.file,
3560
+ options.url.pathname,
3561
+ file => importModule(file, options),
3562
+ ),
3563
+ ))
3504
3564
  const composeMetadata = (pageMeta: Metadata) => {
3505
3565
  const merged = supersededSlotPage
3506
3566
  ? mergeMetadataEntries(layoutRender.metadata, slotMetadata.entries)
@@ -3614,17 +3674,18 @@ async function renderTree(
3614
3674
  getDocumentScriptExtensions().stylesheetPrecedence(options.config),
3615
3675
  )
3616
3676
  const cssHrefStart = profileRenderMark(profile)
3617
- const globalStylesheet =
3677
+ let globalStylesheet =
3618
3678
  // global-not-found replaces the document without the root layout, so the
3619
3679
  // root layout's global sheet must not load (its CSS ships in the route's
3620
3680
  // own chunk, built with includeGlobalCss).
3621
3681
  options.route.id === 'global-not-found' ? undefined : globalCssHref(options.config)
3622
- const routeStylesheet = routeCssHref(options.route, options.config)
3682
+ let routeStylesheet = routeCssHref(options.route, options.config)
3683
+ // File names, not logical ones: the inline path READS these off disk, so it has
3684
+ // to spell them the way the build emitted them (content-hashed in production).
3623
3685
  const stylesheetAssets = [
3624
3686
  ...(globalStylesheet ? ['global.css'] : []),
3625
- ...(options.route.cssAssets ??
3626
- (options.route.cssImports.length ? [`${options.route.id}.css`] : [])),
3627
- ]
3687
+ ...routeCssAssetNames(options.route),
3688
+ ].map(name => emittedAssetName(options.config, name))
3628
3689
  profileRenderSince(profile, 'css hrefs', cssHrefStart)
3629
3690
  const inlineStylesheets = profileRenderSyncStep(profile, 'inline stylesheets', () =>
3630
3691
  getCssExtensions().inlineStylesheets(options.config, {
@@ -3634,6 +3695,18 @@ async function renderTree(
3634
3695
  ...(nonce ? { nonce } : {}),
3635
3696
  }),
3636
3697
  )
3698
+ if (!inlineStylesheets && fonts.css) {
3699
+ const fontAsset = await profileRenderStep(profile, 'font stylesheet', () =>
3700
+ emitFontCssStylesheet(options.config, options.route.id, fonts.css, stylesheetAssets[0], {
3701
+ dev: Boolean(options.dev),
3702
+ }),
3703
+ )
3704
+ const fontHref = assetHref(options.config, fontAsset)
3705
+ if (globalStylesheet) globalStylesheet = fontHref
3706
+ else if (Array.isArray(routeStylesheet))
3707
+ routeStylesheet = [fontHref, ...routeStylesheet.slice(1)]
3708
+ else routeStylesheet = fontHref
3709
+ }
3637
3710
  const stylesheetsStart = profileRenderMark(profile)
3638
3711
  const stylesheets = (
3639
3712
  inlineStylesheets
@@ -3672,9 +3745,11 @@ async function renderTree(
3672
3745
  nextCompatEnabled(options.config) && !options.dev
3673
3746
  ? `<script src="${escapeHtml(withAssetPrefix(options.config, '/_next/static/pnext/_buildManifest.js'))}"></script>`
3674
3747
  : ''
3748
+ // Inlined CSS is a stylesheet, not a script: it belongs at the stylesheet slot so the cascade
3749
+ // (and the emitted order) matches the linked path.
3750
+ const headStyles = inlineStylesheets?.join('\n') ?? ''
3675
3751
  const headScripts = profileRenderSyncStep(profile, 'head scripts', () =>
3676
3752
  [
3677
- inlineStylesheets?.join('\n'),
3678
3753
  renderCollectedHeadScripts(),
3679
3754
  bootstrapScripts,
3680
3755
  compatBuildManifestScript,
@@ -3730,25 +3805,23 @@ async function renderTree(
3730
3805
  resourceHints,
3731
3806
  staticMetadata,
3732
3807
  fontPreloads: fonts.preloads,
3808
+ headStyles,
3733
3809
  headScripts,
3734
3810
  deferredScripts,
3735
3811
  nonce,
3736
3812
  propsScript: extra.propsScript ?? '',
3737
3813
  routeScript,
3738
3814
  clientScript,
3739
- // Whether anything client-side ever mounts INTO the page slot: a whole-page `'use client'` route
3740
- // hydrates on `#pnext-page` itself, and islands are adopted through it. A route that ships the
3741
- // entry only for the router runtime mounts nothing there.
3742
- hasClientMounts: options.route.client || options.route.clientReferences.length > 0,
3815
+ hasClientMounts: pageSlotIsMountTarget(options),
3743
3816
  devScript,
3744
3817
  streamChunks: streamState.deferred,
3745
3818
  lateChunks,
3746
3819
  status: extra.status,
3747
3820
  viewport,
3748
- fontCss: inlineStylesheets ? '' : fonts.css,
3821
+ fontCss: '',
3749
3822
  ...(bodyIconLinks ? { bodyIconLinks } : {}),
3750
3823
  ...(bodyMetadata ? { bodyMetadata } : {}),
3751
- ...(pendingPageMetadata ? { bodyMetadataDeferred: true } : {}),
3824
+ ...(bodyMetadata || pendingPageMetadata ? { metadataInBody: true } : {}),
3752
3825
  compatBodyScript: getDocumentScriptExtensions().documentBodyScripts(options.config),
3753
3826
  // The shell's server-inserted HTML was drained when the stream shell
3754
3827
  // finished (streamState.insertedHead); anything still registered is emitted
@@ -4246,7 +4319,10 @@ async function renderErrorPage(options: RenderOptions, error: unknown, stream: b
4246
4319
  nextCompatEnabled(options.config),
4247
4320
  )
4248
4321
  const reset = errorResetCallback()
4249
- return renderTree(
4322
+ // Awaited inside the try on purpose: a bare `return` hands the rejection to
4323
+ // the caller, and an error boundary that fails to render answered with a
4324
+ // bodiless 500 instead of the generic error document.
4325
+ return await renderTree(
4250
4326
  options,
4251
4327
  h(
4252
4328
  'div',
@@ -4377,6 +4453,22 @@ function resolveThrownError(error: unknown, compatNext: boolean): Error {
4377
4453
  return wrapped ?? new Error(String(error))
4378
4454
  }
4379
4455
 
4456
+ /**
4457
+ * The logged trace, always carrying the error's identity. Bun drops the `Name: message` header when
4458
+ * it source-maps a stack, so a bare frame list would log as `⨯ Error` with nothing to diagnose.
4459
+ */
4460
+ export function errorLogTrace(error: Error): string {
4461
+ const name = error.name || 'Error'
4462
+ const header = error.message ? `${name}: ${error.message}` : name
4463
+ const stack = error.stack
4464
+ if (!stack) return header
4465
+ if (stack.startsWith(header)) return stack
4466
+ // Source-mapping keeps the name and drops `: <message>`, so a stack that opens with the bare name
4467
+ // is a header to complete, not one to prepend - matching on the name alone would lose the message.
4468
+ if (stack.startsWith(`${name}\n`)) return `${header}${stack.slice(name.length)}`
4469
+ return `${header}\n${stack}`
4470
+ }
4471
+
4380
4472
  function logDevError(options: RenderOptions, error: Error, clientSsrError = false) {
4381
4473
  const seen = isBuildError(error) ? loggedBuildErrors : loggedRuntimeErrors
4382
4474
  if (seen.has(error)) return
@@ -4386,7 +4478,7 @@ function logDevError(options: RenderOptions, error: Error, clientSsrError = fals
4386
4478
  // rejection guard and the start.ts top-level catch so whichever site fires
4387
4479
  // first wins (dedupe-rsc-error-log asserts one log per error).
4388
4480
  if (!markErrorLogged(error)) return
4389
- const trace = error.stack ?? error.message
4481
+ const trace = errorLogTrace(error)
4390
4482
  if (clientSsrError) {
4391
4483
  // A client-page SSR error is replayed in the browser (no digest attached),
4392
4484
  // but Next's prod server log still prints it in the `⨯ <trace>` shape —
@@ -5153,6 +5245,7 @@ async function resolvePartialSuspense(
5153
5245
  renderSuspenseReplacement(
5154
5246
  String(id),
5155
5247
  parts.join(''),
5248
+ state.options,
5156
5249
  documentNonce(state.options.request),
5157
5250
  dynamic,
5158
5251
  ),
@@ -5162,7 +5255,12 @@ async function resolvePartialSuspense(
5162
5255
  const resolved = await resolveServerTree(node.props.children, opaque)
5163
5256
  const html = await renderVNodeToString(h(Fragment, null, resolved), opaque)
5164
5257
  partial.chunks.push(
5165
- renderSuspenseReplacement(String(id), html, documentNonce(state.options.request)),
5258
+ renderSuspenseReplacement(
5259
+ String(id),
5260
+ html,
5261
+ state.options,
5262
+ documentNonce(state.options.request),
5263
+ ),
5166
5264
  )
5167
5265
  return null
5168
5266
  }
@@ -5477,6 +5575,7 @@ function deferSuspenseBoundary(
5477
5575
  renderSuspenseReplacement(
5478
5576
  id,
5479
5577
  await renderVNodeToString(h(Fragment, null, resolved), state),
5578
+ state.options,
5480
5579
  nonce,
5481
5580
  ),
5482
5581
  )
@@ -5487,12 +5586,18 @@ function deferSuspenseBoundary(
5487
5586
  // the nearest not-found UI plus a noindex meta, or the redirect meta-refresh and
5488
5587
  // location.replace script. Both ride the chunk's own markup rather than the sent head.
5489
5588
  if (isNotFoundError(error)) {
5490
- return renderSuspenseReplacement(id, await renderStreamNotFoundBoundary(state), nonce)
5589
+ return renderSuspenseReplacement(
5590
+ id,
5591
+ await renderStreamNotFoundBoundary(state),
5592
+ state.options,
5593
+ nonce,
5594
+ )
5491
5595
  }
5492
5596
  if (isRedirectError(error)) {
5493
5597
  return renderSuspenseReplacement(
5494
5598
  id,
5495
5599
  metadataRedirectHtml(error.location, error.status),
5600
+ state.options,
5496
5601
  nonce,
5497
5602
  )
5498
5603
  }
@@ -5513,6 +5618,7 @@ function deferSuspenseBoundary(
5513
5618
  needsClientEntry(state.options.route),
5514
5619
  ),
5515
5620
  ),
5621
+ state.options,
5516
5622
  nonce,
5517
5623
  )
5518
5624
  }),
@@ -5840,6 +5946,17 @@ function elementVNode(
5840
5946
  return h(type as string, props, children)
5841
5947
  }
5842
5948
 
5949
+ /** The one message every core server-action refusal uses; see SERVER_ACTIONS_UNSUPPORTED. */
5950
+ function serverActionUnsupported(options: RenderOptions): Error {
5951
+ return new Error(
5952
+ serverActionsUnsupportedMessage({
5953
+ file: options.route.serverActionFile ?? options.route.file,
5954
+ route: options.route.route,
5955
+ root: options.config.root,
5956
+ }),
5957
+ )
5958
+ }
5959
+
5843
5960
  // Progressive enhancement for <form action={serverAction}>: render a real POST
5844
5961
  // form back to the current page URL with the id in a hidden field, so a no-JS
5845
5962
  // submit still dispatches (serveAction promotes the field to the next-action
@@ -5854,6 +5971,10 @@ function serverActionFormVNode(
5854
5971
  const rest = { ...props }
5855
5972
  delete rest.action
5856
5973
  if (!state) return h('form', rest, children)
5974
+ // Core has no action registry and no dispatch endpoint, so there is nothing to
5975
+ // wire this form to. Dropping the prop rendered a form that silently submits a
5976
+ // GET to the current URL - undebuggable from the browser - so core refuses.
5977
+ if (!nextCompatEnabled(state.options.config)) throw serverActionUnsupported(state.options)
5857
5978
  const enhanced = serverActionFormProps(props, state)
5858
5979
  if (!enhanced) return h('form', rest, children)
5859
5980
  return h('form', enhanced.props, enhanced.hidden, children)
@@ -5907,6 +6028,7 @@ function actionSubmitterVNode(
5907
6028
  state: ActionSerializeState,
5908
6029
  ) {
5909
6030
  const { formAction, ...rest } = props
6031
+ if (!nextCompatEnabled(state.options.config)) throw serverActionUnsupported(state.options)
5910
6032
  const id = actionIdForFunction(formAction, state)
5911
6033
  if (!id) return h(type, rest, children)
5912
6034
  return h(
@@ -5922,6 +6044,25 @@ function actionSubmitterVNode(
5922
6044
  )
5923
6045
  }
5924
6046
 
6047
+ /**
6048
+ * Convention props the framework itself supplies as FUNCTIONS (error.tsx's
6049
+ * `reset`/`unstable_retry`) are documented API, so a `'use client'` boundary
6050
+ * must be able to receive them. They ride the same `$$pnextErrorReset` marker
6051
+ * compat writes; core reads it back in utils/serialize. Convention props are
6052
+ * always top level, so this is one typeof per prop and no copy when there is
6053
+ * nothing to rewrite.
6054
+ */
6055
+ function serializeConventionProps(props: Record<string, unknown>): Record<string, unknown> {
6056
+ let out: Record<string, unknown> | undefined
6057
+ for (const [key, value] of Object.entries(props)) {
6058
+ if (typeof value !== 'function') continue
6059
+ if (!(value as { [PROP_ERROR_RESET_MARKER]?: unknown })[PROP_ERROR_RESET_MARKER]) continue
6060
+ out ??= { ...props }
6061
+ out[key] = { [PROP_ERROR_RESET_MARKER]: true }
6062
+ }
6063
+ return out ?? props
6064
+ }
6065
+
5925
6066
  /**
5926
6067
  * Replace function props (server actions handed to a client island) with the
5927
6068
  * serializable action-reference marker the client runtime revives. Non-action
@@ -5931,7 +6072,7 @@ function serializeActionProps(
5931
6072
  props: Record<string, unknown>,
5932
6073
  state: ActionSerializeState,
5933
6074
  ): Record<string, unknown> {
5934
- if (!nextCompatEnabled(state.options.config)) return props
6075
+ if (!nextCompatEnabled(state.options.config)) return serializeConventionProps(props)
5935
6076
  // Taint check runs on the RAW props: serializeActionValue copies plain (and
5936
6077
  // null-prototype) objects, which would drop the identity `process.env` is
5937
6078
  // recognized by.
@@ -6417,7 +6558,13 @@ async function readRenderModuleMetadata(
6417
6558
  }
6418
6559
  }
6419
6560
 
6420
- function renderSuspenseReplacement(id: string, html: string, nonce?: string, slots?: number[]) {
6561
+ function renderSuspenseReplacement(
6562
+ id: string,
6563
+ html: string,
6564
+ options: RenderOptions,
6565
+ nonce?: string,
6566
+ slots?: number[],
6567
+ ) {
6421
6568
  // Streamed Suspense content rides in a `<div hidden>` (React/Next's own
6422
6569
  // streaming markup shape) rather than a `<template>`: tests (and tools like
6423
6570
  // cheerio) assert streamed-but-unswapped content sits under a `[hidden]`
@@ -6427,7 +6574,11 @@ function renderSuspenseReplacement(id: string, html: string, nonce?: string, slo
6427
6574
  // boundary's dynamic slots (comment-delimited), and promotion merges them with
6428
6575
  // the static slots the shell's `<template data-pnext-static>` already holds.
6429
6576
  const slotsAttribute = slots ? ` data-pnext-slots="${escapeHtml(slots.join(','))}"` : ''
6430
- return `<div hidden data-pnext-stream="${escapeHtml(id)}"${slotsAttribute}>${html}</div><script${nonceAttribute}>${suspenseReplaceScript(id)}</script>`
6577
+ // A page whose slot sits under a Suspense (loading.tsx, a streamed leaf) ships the slot HERE
6578
+ // rather than in the shell, so the same mount-target rule the document body applies has to run
6579
+ // on the tail: $RC grafts these bytes into the live DOM, and an inert wrapper would re-enter it.
6580
+ const body = unwrapUnusedPageSlot(html, streamPageSlotMode(options))
6581
+ return `<div hidden data-pnext-stream="${escapeHtml(id)}"${slotsAttribute}>${body}</div><script${nonceAttribute}>${suspenseReplaceScript(id)}</script>`
6431
6582
  }
6432
6583
 
6433
6584
  async function renderVNodeToString(vnode: VNode, state?: ActionSerializeState) {
@@ -6553,8 +6704,11 @@ async function renderSuspendingStreamShell(
6553
6704
  const refresh = tail.includes('<preact-island')
6554
6705
  ? clientSuspenseRefreshScript(documentNonce(state.options.request))
6555
6706
  : ''
6556
- return serializeNeutralClientIslands(
6557
- `${inserted}${anchorInlineSuspenseHoles(tail)}${refresh}`,
6707
+ // Same mount-target rule as the out-of-order chunks: an inline-suspense tail carries the page
6708
+ // slot whenever the page itself suspended, and the graft lands it in the live DOM.
6709
+ return unwrapUnusedPageSlot(
6710
+ serializeNeutralClientIslands(`${inserted}${anchorInlineSuspenseHoles(tail)}${refresh}`),
6711
+ streamPageSlotMode(state.options),
6558
6712
  )
6559
6713
  }),
6560
6714
  )
@@ -6586,7 +6740,12 @@ function clientSuspenseRefreshScript(nonce: string | undefined) {
6586
6740
  // markers before this script - which streams in the deferred tail - can run, but the element
6587
6741
  // survives adoption as the island's static children. Falls back to the comment markers when the
6588
6742
  // element is absent.
6589
- return `<script${nonceAttribute}>$RC=function(i,d){var h=document.querySelector('pnext-hole[data-pnext-hole="'+i+'"]');if(h){while(h.firstChild)h.removeChild(h.firstChild);while(d.firstChild)h.appendChild(d.firstChild);d.remove();return}var s,e,w=document.createTreeWalker(document,128);while(w.nextNode()){var n=w.currentNode;if(n.data==='$s:'+i)s=n;else if(n.data==='/$s:'+i)e=n;if(s&&e)break}if(!s||!e)return;var p=e.previousSibling;while(p&&p!==s){var x=p;p=p.previousSibling;x.remove()}while(d.firstChild)s.after(d.firstChild),s=s.nextSibling;d.remove()};document.querySelectorAll('preact-island[data-target]').forEach(function(d){$RC(d.getAttribute('data-target'),d)})</script>`
6743
+ //
6744
+ // The grafted content arrives AFTER the document-wide island bootstrap ran, so its islands are
6745
+ // still comment markers no scan can see: materialize and re-mount once the graft is done, the same
6746
+ // pair PROMO_RUNTIME runs for the `<pnext-suspense>` path. Without it every client component inside
6747
+ // a streamed Suspense boundary stays inert server HTML - no hydration, no effects, no data fetch.
6748
+ return `<script${nonceAttribute}>$RC=function(i,d){var h=document.querySelector('pnext-hole[data-pnext-hole="'+i+'"]');if(h){while(h.firstChild)h.removeChild(h.firstChild);while(d.firstChild)h.appendChild(d.firstChild);d.remove();return}var s,e,w=document.createTreeWalker(document,128);while(w.nextNode()){var n=w.currentNode;if(n.data==='$s:'+i)s=n;else if(n.data==='/$s:'+i)e=n;if(s&&e)break}if(!s||!e)return;var p=e.previousSibling;while(p&&p!==s){var x=p;p=p.previousSibling;x.remove()}while(d.firstChild)s.after(d.firstChild),s=s.nextSibling;d.remove()};document.querySelectorAll('preact-island[data-target]').forEach(function(d){$RC(d.getAttribute('data-target'),d)});window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__&&window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__(document);window.__PNEXT_MOUNT_ISLANDS__&&window.__PNEXT_MOUNT_ISLANDS__()</script>`
6590
6749
  }
6591
6750
 
6592
6751
  /**
@@ -6601,11 +6760,16 @@ function islandMarkerBootstrapTag(mode: PageSlotMarkerMode): string {
6601
6760
 
6602
6761
  /**
6603
6762
  * Elements-as-final leaves `div#pnext-page` in the document, but nothing mounts on it unless the
6604
- * slot itself hydrates - and an inert wrapper would break the flat body children a
6605
- * router-runtime-only route renders. Unwrap it.
6763
+ * slot itself hydrates - and an inert wrapper would break the flat body children Next emits.
6764
+ * Elements wire format: unwrap it. Comment wire format: empty the marker's attribute payload, which
6765
+ * every materializer already reads as "no element to build" - so the slot stays a pure anchor pair
6766
+ * on first paint AND on every soft navigation that lands on this document.
6606
6767
  */
6607
6768
  function unwrapUnusedPageSlot(html: string, mode: PageSlotMarkerMode): string {
6608
- if (mode === 'materialize' || getRenderExtensions().islandCommentWireFormat()) return html
6769
+ if (mode === 'materialize') return html
6770
+ if (getRenderExtensions().islandCommentWireFormat()) {
6771
+ return html.replace(/<!--pnext-page:[^>]*-->/, '<!--pnext-page:-->')
6772
+ }
6609
6773
  const slot = pageSlotSpan(html)
6610
6774
  return slot ? html.slice(0, slot.start) + slot.children + html.slice(slot.end) : html
6611
6775
  }
@@ -6671,22 +6835,42 @@ function pageSlotSpan(html: string) {
6671
6835
  * against the live DOM, so `div#pnext-page` may only be rebuilt when something actually mounts on
6672
6836
  * it:
6673
6837
  *
6674
- * - `materialize`: a whole-page client route hydrates on the slot, or islands are adopted through
6675
- * it.
6676
- * - `keep`: the route ships the entry for the ROUTER RUNTIME only. The wrapper is not rebuilt,
6677
- * but the comment anchors stay - the page-slot machinery locates the slot from the comment pair
6678
- * just as well as from the element.
6838
+ * - `materialize`: a whole-page client route hydrates on the slot, or a client ROOT layout
6839
+ * re-renders the page through it.
6840
+ * - `keep`: nothing mounts on the slot - the route ships the entry for islands and/or the router
6841
+ * runtime only. The wrapper is not rebuilt, but the comment anchors stay: the page-slot
6842
+ * machinery locates the slot from the comment pair just as well as from the element.
6679
6843
  * - `drop`: no client runtime at all, so nothing will ever read the anchors.
6680
6844
  */
6681
6845
  type PageSlotMarkerMode = 'materialize' | 'keep' | 'drop'
6682
6846
 
6683
- function pageSlotMarkerMode(page: DocumentPage, body: string): PageSlotMarkerMode {
6847
+ /**
6848
+ * Whether anything client-side ever mounts INTO the page slot. Only two things do: a whole-page
6849
+ * `'use client'` route (it hydrates on `#pnext-page` itself) and a `'use client'` ROOT layout (the
6850
+ * entry's client shell re-renders the document through it and reads the slot as its children).
6851
+ * ISLANDS DO NOT - each mounts on its own `pnext-client` host - so an island-only route keeps Next's
6852
+ * wrapper-free body. Mirrors client/build.ts `hasClientRootLayout`, which decides whether the entry
6853
+ * carries the shell path at all.
6854
+ */
6855
+ function pageSlotIsMountTarget(options: RenderOptions): boolean {
6856
+ if (options.route.client) return true
6857
+ const rootLayout = (options.layoutFiles ??
6858
+ findLayouts(options.config.appPath, options.route.file))[0]
6859
+ return Boolean(rootLayout && options.route.clientReferences.some(ref => ref.file === rootLayout))
6860
+ }
6861
+
6862
+ function pageSlotMarkerMode(page: DocumentPage): PageSlotMarkerMode {
6684
6863
  if (!page.clientScript) return 'drop'
6685
- // The route manifest's client flags are the authority (a client page carries
6686
- // no island marker of its own); the body scan additionally covers islands
6687
- // contributed by an ancestor layout outside this route's own references.
6688
- if (page.hasClientMounts || /<!--pnext-client|<pnext-client\b/.test(body)) return 'materialize'
6689
- return 'keep'
6864
+ return page.hasClientMounts ? 'materialize' : 'keep'
6865
+ }
6866
+
6867
+ /**
6868
+ * The same mode for a streamed tail chunk, which is assembled before the document knows whether it
6869
+ * ships a client script. `keep` and `drop` dissolve the slot identically here (only the bootstrap
6870
+ * script distinguishes them), so the mount-target question is the whole decision.
6871
+ */
6872
+ function streamPageSlotMode(options: RenderOptions): PageSlotMarkerMode {
6873
+ return pageSlotIsMountTarget(options) ? 'materialize' : 'keep'
6690
6874
  }
6691
6875
 
6692
6876
  function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
@@ -6747,45 +6931,48 @@ function hStreamError(
6747
6931
  hasClientRuntime = false,
6748
6932
  ): VNode<any> {
6749
6933
  const resolvedError = resolveThrownError(error, compatNext)
6750
- if (!dev) {
6751
- // Compat: carry the serialized boundary error (redacted message + digest)
6752
- // on the marker so the client error runtime can replace this fallback with
6753
- // the route's error.js boundary (Next renders the nearest error boundary
6754
- // for a server error inside a streamed Suspense hole; its unstable_retry
6755
- // soft-refreshes and recovers). Core keeps the plain generic marker.
6756
- const serialized = compatNext
6757
- ? getRenderExtensions().serializeError({ error: resolvedError, dev })
6758
- : undefined
6759
- const marker = h(
6760
- 'pnext-error',
6761
- {
6762
- 'data-pnext-stream-error': true,
6763
- ...(serialized
6764
- ? {
6765
- 'data-pnext-error-message': serialized.message,
6766
- ...(serialized.digest ? { 'data-pnext-error-digest': serialized.digest } : {}),
6767
- }
6768
- : {}),
6769
- },
6770
- (compatNext
6934
+ // Compat: carry the serialized boundary error (redacted message + digest)
6935
+ // on the marker so the client error runtime can replace this fallback with
6936
+ // the route's error.js boundary (Next renders the nearest error boundary
6937
+ // for a server error inside a streamed Suspense hole; its unstable_retry
6938
+ // soft-refreshes and recovers). Core keeps the plain generic marker.
6939
+ const serialized = compatNext
6940
+ ? getRenderExtensions().serializeError({ error: resolvedError, dev })
6941
+ : undefined
6942
+ // The marker's own content is what stays on screen when nothing escalates it. Dev keeps the
6943
+ // diagnostic card there - Next renders the error boundary in dev too, so the marker must be emitted
6944
+ // either way; substituting the card for the marker stranded the route's error.js.
6945
+ const fallback = dev
6946
+ ? genericErrorTree(resolvedError, true, compatNext)
6947
+ : ((compatNext
6771
6948
  ? getRenderExtensions().genericErrorTitle({ error: resolvedError, dev })
6772
- : undefined) ?? 'Application error',
6773
- )
6774
- // A page with no client components ships no error runtime, so nothing can escalate the marker
6775
- // client-side. Emit a self-contained fallback that swaps in the built-in global-error document
6776
- // unless the runtime announces itself (__PNEXT_STREAM_ERROR_RUNTIME__), in which case it owns
6777
- // the marker instead.
6778
- const fallbackScript =
6779
- compatNext && !hasClientRuntime ? streamErrorDocumentScript(serialized) : undefined
6780
- if (!fallbackScript) return marker
6781
- return h(
6782
- Fragment,
6783
- null,
6784
- marker,
6785
- h('script', { dangerouslySetInnerHTML: { __html: fallbackScript } }),
6786
- )
6787
- }
6788
- return genericErrorTree(resolvedError, true, compatNext)
6949
+ : undefined) ?? 'Application error')
6950
+ const marker = h(
6951
+ 'pnext-error',
6952
+ {
6953
+ 'data-pnext-stream-error': true,
6954
+ ...(serialized
6955
+ ? {
6956
+ 'data-pnext-error-message': serialized.message,
6957
+ ...(serialized.digest ? { 'data-pnext-error-digest': serialized.digest } : {}),
6958
+ }
6959
+ : {}),
6960
+ },
6961
+ fallback,
6962
+ )
6963
+ // A page with no client components ships no error runtime, so nothing can escalate the marker
6964
+ // client-side. Emit a self-contained fallback that swaps in the built-in global-error document
6965
+ // unless the runtime announces itself (__PNEXT_STREAM_ERROR_RUNTIME__), in which case it owns
6966
+ // the marker instead. Dev needs none: its card is already the marker's visible content.
6967
+ const fallbackScript =
6968
+ !dev && compatNext && !hasClientRuntime ? streamErrorDocumentScript(serialized) : undefined
6969
+ if (!fallbackScript) return marker
6970
+ return h(
6971
+ Fragment,
6972
+ null,
6973
+ marker,
6974
+ h('script', { dangerouslySetInnerHTML: { __html: fallbackScript } }),
6975
+ )
6789
6976
  }
6790
6977
 
6791
6978
  // Inline bootstrap flushed after a stream-error marker: replaces the document
@@ -6982,19 +7169,18 @@ async function applyLayouts(
6982
7169
  // postpone its boundary too (not read an empty object). Reuse the page-level
6983
7170
  // hanging params promise so the whole shell is param-independent.
6984
7171
  if (options.fallbackParams) return { ...layoutProps, params: hangingPromise('params') }
7172
+ // The scoped params go through the vary-tracking extension under the LAYOUT
7173
+ // segment kind: compat keys the layout's cache frame on the params this
7174
+ // layout reads, independently of the page's own set. Outside a tracking
7175
+ // scope the extension is the identity, so ordinary renders pay nothing (see
7176
+ // compat/segment/vary-params.ts).
7177
+ const scopedParams = getRenderExtensions().trackVaryParams(
7178
+ layoutParamsForDir(options.config.appPath, dirname(layoutFile), options.params ?? {}),
7179
+ { kind: 'params', segment: 'layout' },
7180
+ )
6985
7181
  return {
6986
7182
  ...layoutProps,
6987
- // The scoped params go through the vary-tracking extension under the
6988
- // LAYOUT segment kind: compat keys the layout's cache frame on the params
6989
- // this layout reads, independently of the page's own set. Outside a
6990
- // tracking scope the extension is the identity, so ordinary renders pay
6991
- // nothing (see compat/segment/vary-params.ts).
6992
- params: Promise.resolve(
6993
- getRenderExtensions().trackVaryParams(
6994
- layoutParamsForDir(options.config.appPath, dirname(layoutFile), options.params ?? {}),
6995
- { kind: 'params', segment: 'layout' },
6996
- ),
6997
- ),
7183
+ params: legacySyncProps(Promise.resolve(scopedParams), 'params', scopedParams),
6998
7184
  }
6999
7185
  }
7000
7186