@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
@@ -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,7 +4453,24 @@ 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) {
4473
+ if (error.name === 'DynamicServerError') return
4381
4474
  const seen = isBuildError(error) ? loggedBuildErrors : loggedRuntimeErrors
4382
4475
  if (seen.has(error)) return
4383
4476
  seen.add(error)
@@ -4386,7 +4479,7 @@ function logDevError(options: RenderOptions, error: Error, clientSsrError = fals
4386
4479
  // rejection guard and the start.ts top-level catch so whichever site fires
4387
4480
  // first wins (dedupe-rsc-error-log asserts one log per error).
4388
4481
  if (!markErrorLogged(error)) return
4389
- const trace = error.stack ?? error.message
4482
+ const trace = errorLogTrace(error)
4390
4483
  if (clientSsrError) {
4391
4484
  // A client-page SSR error is replayed in the browser (no digest attached),
4392
4485
  // but Next's prod server log still prints it in the `⨯ <trace>` shape —
@@ -5153,6 +5246,7 @@ async function resolvePartialSuspense(
5153
5246
  renderSuspenseReplacement(
5154
5247
  String(id),
5155
5248
  parts.join(''),
5249
+ state.options,
5156
5250
  documentNonce(state.options.request),
5157
5251
  dynamic,
5158
5252
  ),
@@ -5162,7 +5256,12 @@ async function resolvePartialSuspense(
5162
5256
  const resolved = await resolveServerTree(node.props.children, opaque)
5163
5257
  const html = await renderVNodeToString(h(Fragment, null, resolved), opaque)
5164
5258
  partial.chunks.push(
5165
- renderSuspenseReplacement(String(id), html, documentNonce(state.options.request)),
5259
+ renderSuspenseReplacement(
5260
+ String(id),
5261
+ html,
5262
+ state.options,
5263
+ documentNonce(state.options.request),
5264
+ ),
5166
5265
  )
5167
5266
  return null
5168
5267
  }
@@ -5477,6 +5576,7 @@ function deferSuspenseBoundary(
5477
5576
  renderSuspenseReplacement(
5478
5577
  id,
5479
5578
  await renderVNodeToString(h(Fragment, null, resolved), state),
5579
+ state.options,
5480
5580
  nonce,
5481
5581
  ),
5482
5582
  )
@@ -5487,12 +5587,18 @@ function deferSuspenseBoundary(
5487
5587
  // the nearest not-found UI plus a noindex meta, or the redirect meta-refresh and
5488
5588
  // location.replace script. Both ride the chunk's own markup rather than the sent head.
5489
5589
  if (isNotFoundError(error)) {
5490
- return renderSuspenseReplacement(id, await renderStreamNotFoundBoundary(state), nonce)
5590
+ return renderSuspenseReplacement(
5591
+ id,
5592
+ await renderStreamNotFoundBoundary(state),
5593
+ state.options,
5594
+ nonce,
5595
+ )
5491
5596
  }
5492
5597
  if (isRedirectError(error)) {
5493
5598
  return renderSuspenseReplacement(
5494
5599
  id,
5495
5600
  metadataRedirectHtml(error.location, error.status),
5601
+ state.options,
5496
5602
  nonce,
5497
5603
  )
5498
5604
  }
@@ -5513,6 +5619,7 @@ function deferSuspenseBoundary(
5513
5619
  needsClientEntry(state.options.route),
5514
5620
  ),
5515
5621
  ),
5622
+ state.options,
5516
5623
  nonce,
5517
5624
  )
5518
5625
  }),
@@ -5840,6 +5947,17 @@ function elementVNode(
5840
5947
  return h(type as string, props, children)
5841
5948
  }
5842
5949
 
5950
+ /** The one message every core server-action refusal uses; see SERVER_ACTIONS_UNSUPPORTED. */
5951
+ function serverActionUnsupported(options: RenderOptions): Error {
5952
+ return new Error(
5953
+ serverActionsUnsupportedMessage({
5954
+ file: options.route.serverActionFile ?? options.route.file,
5955
+ route: options.route.route,
5956
+ root: options.config.root,
5957
+ }),
5958
+ )
5959
+ }
5960
+
5843
5961
  // Progressive enhancement for <form action={serverAction}>: render a real POST
5844
5962
  // form back to the current page URL with the id in a hidden field, so a no-JS
5845
5963
  // submit still dispatches (serveAction promotes the field to the next-action
@@ -5854,6 +5972,10 @@ function serverActionFormVNode(
5854
5972
  const rest = { ...props }
5855
5973
  delete rest.action
5856
5974
  if (!state) return h('form', rest, children)
5975
+ // Core has no action registry and no dispatch endpoint, so there is nothing to
5976
+ // wire this form to. Dropping the prop rendered a form that silently submits a
5977
+ // GET to the current URL - undebuggable from the browser - so core refuses.
5978
+ if (!nextCompatEnabled(state.options.config)) throw serverActionUnsupported(state.options)
5857
5979
  const enhanced = serverActionFormProps(props, state)
5858
5980
  if (!enhanced) return h('form', rest, children)
5859
5981
  return h('form', enhanced.props, enhanced.hidden, children)
@@ -5907,6 +6029,7 @@ function actionSubmitterVNode(
5907
6029
  state: ActionSerializeState,
5908
6030
  ) {
5909
6031
  const { formAction, ...rest } = props
6032
+ if (!nextCompatEnabled(state.options.config)) throw serverActionUnsupported(state.options)
5910
6033
  const id = actionIdForFunction(formAction, state)
5911
6034
  if (!id) return h(type, rest, children)
5912
6035
  return h(
@@ -5922,6 +6045,25 @@ function actionSubmitterVNode(
5922
6045
  )
5923
6046
  }
5924
6047
 
6048
+ /**
6049
+ * Convention props the framework itself supplies as FUNCTIONS (error.tsx's
6050
+ * `reset`/`unstable_retry`) are documented API, so a `'use client'` boundary
6051
+ * must be able to receive them. They ride the same `$$pnextErrorReset` marker
6052
+ * compat writes; core reads it back in utils/serialize. Convention props are
6053
+ * always top level, so this is one typeof per prop and no copy when there is
6054
+ * nothing to rewrite.
6055
+ */
6056
+ function serializeConventionProps(props: Record<string, unknown>): Record<string, unknown> {
6057
+ let out: Record<string, unknown> | undefined
6058
+ for (const [key, value] of Object.entries(props)) {
6059
+ if (typeof value !== 'function') continue
6060
+ if (!(value as { [PROP_ERROR_RESET_MARKER]?: unknown })[PROP_ERROR_RESET_MARKER]) continue
6061
+ out ??= { ...props }
6062
+ out[key] = { [PROP_ERROR_RESET_MARKER]: true }
6063
+ }
6064
+ return out ?? props
6065
+ }
6066
+
5925
6067
  /**
5926
6068
  * Replace function props (server actions handed to a client island) with the
5927
6069
  * serializable action-reference marker the client runtime revives. Non-action
@@ -5931,7 +6073,7 @@ function serializeActionProps(
5931
6073
  props: Record<string, unknown>,
5932
6074
  state: ActionSerializeState,
5933
6075
  ): Record<string, unknown> {
5934
- if (!nextCompatEnabled(state.options.config)) return props
6076
+ if (!nextCompatEnabled(state.options.config)) return serializeConventionProps(props)
5935
6077
  // Taint check runs on the RAW props: serializeActionValue copies plain (and
5936
6078
  // null-prototype) objects, which would drop the identity `process.env` is
5937
6079
  // recognized by.
@@ -6417,7 +6559,13 @@ async function readRenderModuleMetadata(
6417
6559
  }
6418
6560
  }
6419
6561
 
6420
- function renderSuspenseReplacement(id: string, html: string, nonce?: string, slots?: number[]) {
6562
+ function renderSuspenseReplacement(
6563
+ id: string,
6564
+ html: string,
6565
+ options: RenderOptions,
6566
+ nonce?: string,
6567
+ slots?: number[],
6568
+ ) {
6421
6569
  // Streamed Suspense content rides in a `<div hidden>` (React/Next's own
6422
6570
  // streaming markup shape) rather than a `<template>`: tests (and tools like
6423
6571
  // cheerio) assert streamed-but-unswapped content sits under a `[hidden]`
@@ -6427,7 +6575,11 @@ function renderSuspenseReplacement(id: string, html: string, nonce?: string, slo
6427
6575
  // boundary's dynamic slots (comment-delimited), and promotion merges them with
6428
6576
  // the static slots the shell's `<template data-pnext-static>` already holds.
6429
6577
  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>`
6578
+ // A page whose slot sits under a Suspense (loading.tsx, a streamed leaf) ships the slot HERE
6579
+ // rather than in the shell, so the same mount-target rule the document body applies has to run
6580
+ // on the tail: $RC grafts these bytes into the live DOM, and an inert wrapper would re-enter it.
6581
+ const body = unwrapUnusedPageSlot(html, streamPageSlotMode(options))
6582
+ return `<div hidden data-pnext-stream="${escapeHtml(id)}"${slotsAttribute}>${body}</div><script${nonceAttribute}>${suspenseReplaceScript(id)}</script>`
6431
6583
  }
6432
6584
 
6433
6585
  async function renderVNodeToString(vnode: VNode, state?: ActionSerializeState) {
@@ -6553,8 +6705,11 @@ async function renderSuspendingStreamShell(
6553
6705
  const refresh = tail.includes('<preact-island')
6554
6706
  ? clientSuspenseRefreshScript(documentNonce(state.options.request))
6555
6707
  : ''
6556
- return serializeNeutralClientIslands(
6557
- `${inserted}${anchorInlineSuspenseHoles(tail)}${refresh}`,
6708
+ // Same mount-target rule as the out-of-order chunks: an inline-suspense tail carries the page
6709
+ // slot whenever the page itself suspended, and the graft lands it in the live DOM.
6710
+ return unwrapUnusedPageSlot(
6711
+ serializeNeutralClientIslands(`${inserted}${anchorInlineSuspenseHoles(tail)}${refresh}`),
6712
+ streamPageSlotMode(state.options),
6558
6713
  )
6559
6714
  }),
6560
6715
  )
@@ -6579,14 +6734,19 @@ function anchorInlineSuspenseHoles(html: string): string {
6579
6734
  .replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
6580
6735
  }
6581
6736
 
6582
- function clientSuspenseRefreshScript(nonce: string | undefined) {
6737
+ export function clientSuspenseRefreshScript(nonce: string | undefined) {
6583
6738
  const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
6584
6739
  // Graft each streamed island's content over its hole. The hole is a `<pnext-hole>` element rather
6585
6740
  // than preact's `$s:ID` comment markers: hydration of an ancestor client layout removes comment
6586
6741
  // markers before this script - which streams in the deferred tail - can run, but the element
6587
6742
  // survives adoption as the island's static children. Falls back to the comment markers when the
6588
6743
  // 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>`
6744
+ //
6745
+ // The grafted content arrives AFTER the document-wide island bootstrap ran, so its islands are
6746
+ // still comment markers no scan can see: materialize and re-mount once the graft is done, the same
6747
+ // pair PROMO_RUNTIME runs for the `<pnext-suspense>` path. Without it every client component inside
6748
+ // a streamed Suspense boundary stays inert server HTML - no hydration, no effects, no data fetch.
6749
+ 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
6750
  }
6591
6751
 
6592
6752
  /**
@@ -6601,11 +6761,16 @@ function islandMarkerBootstrapTag(mode: PageSlotMarkerMode): string {
6601
6761
 
6602
6762
  /**
6603
6763
  * 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.
6764
+ * slot itself hydrates - and an inert wrapper would break the flat body children Next emits.
6765
+ * Elements wire format: unwrap it. Comment wire format: empty the marker's attribute payload, which
6766
+ * every materializer already reads as "no element to build" - so the slot stays a pure anchor pair
6767
+ * on first paint AND on every soft navigation that lands on this document.
6606
6768
  */
6607
6769
  function unwrapUnusedPageSlot(html: string, mode: PageSlotMarkerMode): string {
6608
- if (mode === 'materialize' || getRenderExtensions().islandCommentWireFormat()) return html
6770
+ if (mode === 'materialize') return html
6771
+ if (getRenderExtensions().islandCommentWireFormat()) {
6772
+ return html.replace(/<!--pnext-page:[^>]*-->/, '<!--pnext-page:-->')
6773
+ }
6609
6774
  const slot = pageSlotSpan(html)
6610
6775
  return slot ? html.slice(0, slot.start) + slot.children + html.slice(slot.end) : html
6611
6776
  }
@@ -6671,25 +6836,45 @@ function pageSlotSpan(html: string) {
6671
6836
  * against the live DOM, so `div#pnext-page` may only be rebuilt when something actually mounts on
6672
6837
  * it:
6673
6838
  *
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.
6839
+ * - `materialize`: a whole-page client route hydrates on the slot, or a client ROOT layout
6840
+ * re-renders the page through it.
6841
+ * - `keep`: nothing mounts on the slot - the route ships the entry for islands and/or the router
6842
+ * runtime only. The wrapper is not rebuilt, but the comment anchors stay: the page-slot
6843
+ * machinery locates the slot from the comment pair just as well as from the element.
6679
6844
  * - `drop`: no client runtime at all, so nothing will ever read the anchors.
6680
6845
  */
6681
6846
  type PageSlotMarkerMode = 'materialize' | 'keep' | 'drop'
6682
6847
 
6683
- function pageSlotMarkerMode(page: DocumentPage, body: string): PageSlotMarkerMode {
6848
+ /**
6849
+ * Whether anything client-side ever mounts INTO the page slot. Only two things do: a whole-page
6850
+ * `'use client'` route (it hydrates on `#pnext-page` itself) and a `'use client'` ROOT layout (the
6851
+ * entry's client shell re-renders the document through it and reads the slot as its children).
6852
+ * ISLANDS DO NOT - each mounts on its own `pnext-client` host - so an island-only route keeps Next's
6853
+ * wrapper-free body. Mirrors client/build.ts `hasClientRootLayout`, which decides whether the entry
6854
+ * carries the shell path at all.
6855
+ */
6856
+ function pageSlotIsMountTarget(options: RenderOptions): boolean {
6857
+ if (options.route.client) return true
6858
+ const rootLayout = (options.layoutFiles ??
6859
+ findLayouts(options.config.appPath, options.route.file))[0]
6860
+ return Boolean(rootLayout && options.route.clientReferences.some(ref => ref.file === rootLayout))
6861
+ }
6862
+
6863
+ function pageSlotMarkerMode(page: DocumentPage): PageSlotMarkerMode {
6684
6864
  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'
6865
+ return page.hasClientMounts ? 'materialize' : 'keep'
6690
6866
  }
6691
6867
 
6692
- function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6868
+ /**
6869
+ * The same mode for a streamed tail chunk, which is assembled before the document knows whether it
6870
+ * ships a client script. `keep` and `drop` dissolve the slot identically here (only the bootstrap
6871
+ * script distinguishes them), so the mount-target question is the whole decision.
6872
+ */
6873
+ function streamPageSlotMode(options: RenderOptions): PageSlotMarkerMode {
6874
+ return pageSlotIsMountTarget(options) ? 'materialize' : 'keep'
6875
+ }
6876
+
6877
+ export function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6693
6878
  // Pages that mount nothing on #pnext-page never need the wrapper div: the
6694
6879
  // page's children stay direct children of <body>, matching Next's document
6695
6880
  // structure (tests assert structural selectors like `body > p`). `keep`
@@ -6747,45 +6932,48 @@ function hStreamError(
6747
6932
  hasClientRuntime = false,
6748
6933
  ): VNode<any> {
6749
6934
  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
6935
+ // Compat: carry the serialized boundary error (redacted message + digest)
6936
+ // on the marker so the client error runtime can replace this fallback with
6937
+ // the route's error.js boundary (Next renders the nearest error boundary
6938
+ // for a server error inside a streamed Suspense hole; its unstable_retry
6939
+ // soft-refreshes and recovers). Core keeps the plain generic marker.
6940
+ const serialized = compatNext
6941
+ ? getRenderExtensions().serializeError({ error: resolvedError, dev })
6942
+ : undefined
6943
+ // The marker's own content is what stays on screen when nothing escalates it. Dev keeps the
6944
+ // diagnostic card there - Next renders the error boundary in dev too, so the marker must be emitted
6945
+ // either way; substituting the card for the marker stranded the route's error.js.
6946
+ const fallback = dev
6947
+ ? genericErrorTree(resolvedError, true, compatNext)
6948
+ : ((compatNext
6771
6949
  ? 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)
6950
+ : undefined) ?? 'Application error')
6951
+ const marker = h(
6952
+ 'pnext-error',
6953
+ {
6954
+ 'data-pnext-stream-error': true,
6955
+ ...(serialized
6956
+ ? {
6957
+ 'data-pnext-error-message': serialized.message,
6958
+ ...(serialized.digest ? { 'data-pnext-error-digest': serialized.digest } : {}),
6959
+ }
6960
+ : {}),
6961
+ },
6962
+ fallback,
6963
+ )
6964
+ // A page with no client components ships no error runtime, so nothing can escalate the marker
6965
+ // client-side. Emit a self-contained fallback that swaps in the built-in global-error document
6966
+ // unless the runtime announces itself (__PNEXT_STREAM_ERROR_RUNTIME__), in which case it owns
6967
+ // the marker instead. Dev needs none: its card is already the marker's visible content.
6968
+ const fallbackScript =
6969
+ !dev && compatNext && !hasClientRuntime ? streamErrorDocumentScript(serialized) : undefined
6970
+ if (!fallbackScript) return marker
6971
+ return h(
6972
+ Fragment,
6973
+ null,
6974
+ marker,
6975
+ h('script', { dangerouslySetInnerHTML: { __html: fallbackScript } }),
6976
+ )
6789
6977
  }
6790
6978
 
6791
6979
  // Inline bootstrap flushed after a stream-error marker: replaces the document
@@ -6982,19 +7170,18 @@ async function applyLayouts(
6982
7170
  // postpone its boundary too (not read an empty object). Reuse the page-level
6983
7171
  // hanging params promise so the whole shell is param-independent.
6984
7172
  if (options.fallbackParams) return { ...layoutProps, params: hangingPromise('params') }
7173
+ // The scoped params go through the vary-tracking extension under the LAYOUT
7174
+ // segment kind: compat keys the layout's cache frame on the params this
7175
+ // layout reads, independently of the page's own set. Outside a tracking
7176
+ // scope the extension is the identity, so ordinary renders pay nothing (see
7177
+ // compat/segment/vary-params.ts).
7178
+ const scopedParams = getRenderExtensions().trackVaryParams(
7179
+ layoutParamsForDir(options.config.appPath, dirname(layoutFile), options.params ?? {}),
7180
+ { kind: 'params', segment: 'layout' },
7181
+ )
6985
7182
  return {
6986
7183
  ...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
- ),
7184
+ params: legacySyncProps(Promise.resolve(scopedParams), 'params', scopedParams),
6998
7185
  }
6999
7186
  }
7000
7187