@wular/pnext 0.0.24 → 0.1.1

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 (54) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/reference/navigation.md +2 -2
  6. package/src/api/client-navigation.ts +7 -5
  7. package/src/cli/adapters/vercel-warm.ts +120 -28
  8. package/src/cli/build.ts +56 -15
  9. package/src/cli/create.ts +1 -1
  10. package/src/cli/dev.ts +2 -1
  11. package/src/cli/index.ts +146 -10
  12. package/src/cli/migrate/report.ts +1 -1
  13. package/src/cli/serve/pipeline.ts +23 -6
  14. package/src/cli/serve/ui.ts +1 -1
  15. package/src/cli/start.ts +2 -1
  16. package/src/cli/typegen.ts +5 -2
  17. package/src/client/build.ts +217 -58
  18. package/src/client/chunk-fold.ts +7 -1
  19. package/src/client/entry.ts +44 -7
  20. package/src/client/router/runtime.ts +1099 -236
  21. package/src/client/router/types.ts +15 -0
  22. package/src/compat/actions/client-plugin.ts +10 -0
  23. package/src/compat/actions/rewrite.ts +1 -0
  24. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  25. package/src/compat/client/navigation-scroll.ts +29 -3
  26. package/src/compat/client/optimistic-routing.ts +2 -2
  27. package/src/compat/client/segment-cache-policy.ts +4 -18
  28. package/src/compat/client/segment-cache.ts +104 -0
  29. package/src/compat/client/segment-prefetch.ts +6 -6
  30. package/src/compat/next/config-loader.ts +6 -4
  31. package/src/compat/next/navigation.ts +16 -2
  32. package/src/compat/pages/router.ts +102 -9
  33. package/src/compat/protocol.ts +10 -1
  34. package/src/compat/register/actions.ts +2 -0
  35. package/src/compat/register/bundler.ts +6 -6
  36. package/src/compat/register/routing.ts +3 -4
  37. package/src/compat/tsconfig-defaults.ts +3 -2
  38. package/src/config.ts +3 -3
  39. package/src/css/postcss.ts +450 -17
  40. package/src/dev/client-actions.ts +2 -0
  41. package/src/dev/server.ts +87 -14
  42. package/src/render/island-context.ts +3 -0
  43. package/src/render/ppr.ts +14 -0
  44. package/src/render/renderer.ts +273 -43
  45. package/src/request/context.ts +32 -11
  46. package/src/resolve/scan-facts.ts +23 -4
  47. package/src/routing/proxy.ts +29 -25
  48. package/src/routing/routes.ts +1 -1
  49. package/src/runtime/loader.ts +47 -1
  50. package/src/runtime/modules.ts +546 -65
  51. package/src/runtime/vendor-build.ts +109 -22
  52. package/src/runtime/vendor.ts +7 -3
  53. package/src/utils/ansi.ts +3 -1
  54. package/src/utils/fs.ts +17 -3
@@ -80,6 +80,7 @@ import {
80
80
  currentPrerenderScope,
81
81
  hangingPromise,
82
82
  isCachedComponent,
83
+ isCsrBailoutPrerender,
83
84
  isHangingPromise,
84
85
  isPrerendering,
85
86
  isPostpone,
@@ -89,6 +90,7 @@ import {
89
90
  markRuntimeApiOnAwait,
90
91
  PostponeError,
91
92
  runInPrerender,
93
+ runWithCsrBailout,
92
94
  runWithPrerenderDeterminism,
93
95
  runWithRuntimePrefetchSyncIoAbort,
94
96
  runWithTaskBoundaryArmed,
@@ -188,6 +190,7 @@ import {
188
190
  type SlotContext,
189
191
  } from './slots'
190
192
  import {
193
+ CsrBailoutContext,
191
194
  LayoutSegmentContext,
192
195
  RouteParamsContext,
193
196
  type LayoutSegmentSnapshot,
@@ -224,6 +227,34 @@ const LOADING_THROUGH_PROP = '__pnextLoadingThrough'
224
227
  const LOADING_DEPTH_PROP = '__pnextLoadingDepth'
225
228
  const LOADING_DEPTH_ATTRIBUTE = 'data-pnext-loading-depth'
226
229
 
230
+ // The inline-suspense wire form carries no vnode props: preact writes the fallback between
231
+ // bare `<!--$s:ID-->` comments, so a loading.js boundary there cannot prove itself to the
232
+ // navigation runtime and its fallback never paints (searchparams-reuse-loading "should re-use
233
+ // the prefetched loading state"). Lead the fallback with this empty element instead;
234
+ // `anchorInlineSuspenseHoles` lifts its attribute onto the hole and drops it.
235
+ const LOADING_DEPTH_TAG = 'pnext-loading-depth'
236
+
237
+ /** Lead a loading.js fallback with its depth marker. Lifted onto the hole, scrubbed otherwise. */
238
+ function markInlineLoadingFallback(
239
+ fallback: ComponentChildren,
240
+ depth: number | undefined,
241
+ ): ComponentChildren {
242
+ if (depth === undefined) return fallback
243
+ return h(
244
+ Fragment,
245
+ null,
246
+ h(LOADING_DEPTH_TAG, { [LOADING_DEPTH_ATTRIBUTE]: String(depth) }),
247
+ fallback,
248
+ )
249
+ }
250
+
251
+ /** Drop depth markers no hole claimed - the marker wire form carries the attribute itself. */
252
+ function dropLoadingDepthMarkers(html: string): string {
253
+ return html.includes(LOADING_DEPTH_TAG)
254
+ ? html.replace(new RegExp(`<${LOADING_DEPTH_TAG}[^>]*></${LOADING_DEPTH_TAG}>`, 'g'), '')
255
+ : html
256
+ }
257
+
227
258
  /** The `data-pnext-loading-depth` attributes for a Suspense vnode, if any. */
228
259
  function loadingDepthAttributes(props: unknown): Record<string, string> {
229
260
  const depth = (props as Record<string, unknown>)[LOADING_DEPTH_PROP]
@@ -543,6 +574,8 @@ interface LayoutRender {
543
574
  tree: ComponentChildren
544
575
  metadata: MetadataEntry[]
545
576
  runtimeMetadata: boolean
577
+ /** A layout's `generateMetadata` awaited real I/O, so the shell would have flushed without it. */
578
+ metadataMissedFlush: boolean
546
579
  slotMatches: ResolvedSlotMatch[]
547
580
  viewport: Viewport[]
548
581
  documentLayoutFile?: string
@@ -584,6 +617,8 @@ interface ResolveState {
584
617
  clientComponents: ClientComponentMap
585
618
  clientReferences: ClientReferenceMap
586
619
  partial?: PartialState
620
+ /** Nearest Suspense fallback for a static client-rendering bailout. */
621
+ csrBailoutFallback?: ComponentChildren
587
622
  /**
588
623
  * Static-skeleton pass for a postponed boundary: a server component that
589
624
  * postpones resolves to null instead of failing the pass, so the boundary's
@@ -860,7 +895,7 @@ function renderDocumentStart(page: DocumentPage) {
860
895
  ${page.clientScript}
861
896
  ${page.deferredScripts ?? ''}
862
897
  ${page.devScript}`
863
- return stampDocumentNonce(html, page.nonce)
898
+ return dropLoadingDepthMarkers(stampDocumentNonce(html, page.nonce))
864
899
  }
865
900
 
866
901
  // CSP nonce stamping. A nonce on the request's Content-Security-Policy header is applied,
@@ -1489,16 +1524,24 @@ export async function renderPageWithStatus(
1489
1524
  () =>
1490
1525
  runWithFontScope(() =>
1491
1526
  runWithCacheScope(async () => {
1492
- const page = await pageRender(options, false)
1493
- const location =
1494
- page.headers && !(page.headers instanceof Headers) && !Array.isArray(page.headers)
1495
- ? page.headers.location
1496
- : undefined
1497
- return {
1498
- html: renderDocument(page),
1499
- status: page.status ?? 200,
1500
- ...(location ? { location } : {}),
1527
+ const render = async () => {
1528
+ const page = await pageRender(options, false)
1529
+ const location =
1530
+ page.headers && !(page.headers instanceof Headers) && !Array.isArray(page.headers)
1531
+ ? page.headers.location
1532
+ : undefined
1533
+ return {
1534
+ html: renderDocument(page),
1535
+ status: page.status ?? 200,
1536
+ ...(location ? { location } : {}),
1537
+ }
1501
1538
  }
1539
+ const staticGeneration =
1540
+ options.route.segmentConfig?.dynamic !== 'force-static' &&
1541
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
1542
+ process.env.NEXT_PHASE === 'phase-production-build'
1543
+ if (!staticGeneration) return render()
1544
+ return runWithCsrBailout(render)
1502
1545
  }),
1503
1546
  ),
1504
1547
  options.params ?? {},
@@ -1601,7 +1644,8 @@ export async function renderPageResponse(options: RenderOptions) {
1601
1644
  canStreamRoute(options) && !isDomBotRequest(options),
1602
1645
  )
1603
1646
  if (page.headers) {
1604
- return new Response(null, {
1647
+ const location = readLocationHeader(page.headers)
1648
+ return new Response(method === 'HEAD' ? null : (location ?? null), {
1605
1649
  status: page.status ?? 307,
1606
1650
  headers: page.headers,
1607
1651
  })
@@ -2950,11 +2994,12 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
2950
2994
  const prebuiltPageMetadata = pageFile ? options.staticModuleMetadata?.[pageFile] : undefined
2951
2995
  const pageMetadata = pageModule
2952
2996
  ? (parent: Metadata) =>
2953
- (metadataExtensions.readModuleMetadata
2954
- ? metadataExtensions.readModuleMetadata(pageModuleRecord!, props, parent, pageFile)
2955
- : prebuiltPageMetadata?.metadata
2956
- ? Promise.resolve(prebuiltPageMetadata.metadata)
2957
- : readModuleMetadata(pageModule)
2997
+ trackMetadataRedirect(
2998
+ metadataExtensions.readModuleMetadata
2999
+ ? metadataExtensions.readModuleMetadata(pageModuleRecord!, props, parent, pageFile)
3000
+ : prebuiltPageMetadata?.metadata
3001
+ ? Promise.resolve(prebuiltPageMetadata.metadata)
3002
+ : readModuleMetadata(pageModule),
2958
3003
  ).then(meta => meta ?? {})
2959
3004
  : {}
2960
3005
  const viewportStart = profileRenderMark(profile)
@@ -3011,7 +3056,11 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
3011
3056
  // static artifact with no `postponed`
3012
3057
  // (cache-components-errors "notFound() with dynamic ...").
3013
3058
  if (options.runtimeRegen && (isRedirectError(error) || isNotFoundError(error))) throw error
3014
- if (isRedirectError(error)) return renderRedirectPage(error.location, error.status)
3059
+ if (isRedirectError(error)) {
3060
+ return shouldRenderDocumentRedirect(options, error)
3061
+ ? renderDocumentRedirect(options, stream, error.location, error.status)
3062
+ : renderRedirectPage(error.location, error.status)
3063
+ }
3015
3064
  if (isNotFoundError(error)) return renderNotFoundPage(options, stream)
3016
3065
  // authInterrupts (COMPAT-ONLY): forbidden()/unauthorized() render their own
3017
3066
  // convention boundary with a dedicated status. They are gated behind
@@ -3314,6 +3363,67 @@ function renderRedirectPage(location: string, status: number): PageRender {
3314
3363
  }
3315
3364
  }
3316
3365
 
3366
+ function readLocationHeader(headers: HeadersInit): string | null {
3367
+ if (headers instanceof Headers) return headers.get('location')
3368
+ if (Array.isArray(headers)) return new Headers(headers).get('location')
3369
+ return headers.location ?? null
3370
+ }
3371
+
3372
+ /**
3373
+ * Redirect errors that escaped a `generateMetadata` call. Next renders metadata inside a
3374
+ * Suspense boundary, so a redirect thrown there resolves AFTER the response status is fixed:
3375
+ * the document is a 200 carrying the meta-refresh envelope, whatever the route's mode
3376
+ * (metadata-navigation "should support redirect in generateMetadata").
3377
+ */
3378
+ const metadataRedirects = new WeakSet<object>()
3379
+
3380
+ function noteMetadataRedirect(error: unknown): void {
3381
+ if (isRedirectError(error) && typeof error === 'object') metadataRedirects.add(error)
3382
+ }
3383
+
3384
+ /**
3385
+ * Tag a metadata promise's redirect rejection. Observes the SAME promise rather than chaining
3386
+ * off it: an extra microtask here delays metadata resolution by a tick, which is enough to flip
3387
+ * the head-vs-body streaming decision (metadata-streaming "should delay the metadata render to
3388
+ * body").
3389
+ */
3390
+ function trackMetadataRedirect<T>(promise: Promise<T>): Promise<T> {
3391
+ void promise.catch(noteMetadataRedirect)
3392
+ return promise
3393
+ }
3394
+
3395
+ function shouldRenderDocumentRedirect(options: RenderOptions, error: unknown): boolean {
3396
+ if (!nextCompatEnabled(options.config)) return false
3397
+ const request = options.request
3398
+ if (request?.method.toUpperCase() !== 'GET') return false
3399
+ if (request.headers.get('rsc') === '1') return false
3400
+ // A page/layout redirect on a dynamic route still answers with a real 307 - the render
3401
+ // throws before anything is flushed. Metadata is the exception: it is suspended, so its
3402
+ // redirect lands after the status - unless this render BLOCKS on metadata (html-limited
3403
+ // bot), where the redirect is a real 307 again.
3404
+ if (options.route.mode !== 'dynamic') return true
3405
+ return metadataRedirects.has(error as object) && !isHtmlLimitedBotRequest(options)
3406
+ }
3407
+
3408
+ async function renderDocumentRedirect(
3409
+ options: RenderOptions,
3410
+ stream: boolean,
3411
+ location: string,
3412
+ status: number,
3413
+ ): Promise<PageRender> {
3414
+ const page = await renderTreeOrFallback(options, h('div', pageRootProps), stream, {})
3415
+ const seconds = status === 308 ? 0 : 1
3416
+ const meta = `<meta id="__next-page-redirect" http-equiv="refresh" content="${seconds};url=${escapeHtml(location)}"/>`
3417
+ const digest = `NEXT_REDIRECT;replace;${location};${status};`
3418
+ const flight = JSON.stringify(`E${JSON.stringify({ digest })}`).replace(/</g, '\\u003c')
3419
+ return {
3420
+ ...page,
3421
+ head: [page.head, meta].filter(Boolean).join('\n '),
3422
+ routeScript: `${page.routeScript}<script>self.__next_f=self.__next_f||[]</script><script>self.__next_f.push([1,${flight}])</script>`,
3423
+ status: 200,
3424
+ }
3425
+ }
3426
+
3317
3427
  async function resolveSelectedSlotMetadata(
3318
3428
  options: RenderOptions,
3319
3429
  matches: ResolvedSlotMatch[],
@@ -3387,7 +3497,9 @@ async function renderTreeInFrame(
3387
3497
  if (options.dev && lazyClientReferencePreload()) {
3388
3498
  // Lazy mode: the preload keeps warming in the background; the render
3389
3499
  // blocks only on the references it reaches (ensureClientComponent),
3390
- // seeded with a warm route's settled marking.
3500
+ // seeded with a warm route's settled marking. Soft navigations receive
3501
+ // only that settled snapshot from preloadClientReferences; discovery
3502
+ // below joins the references present in this render instead of the route.
3391
3503
  options.clientComponentsPreload.catch(() => undefined)
3392
3504
  const settled = settledMarkedReferences.get(options.route.id)
3393
3505
  if (settled) mergeClientComponents(clientComponents, settled)
@@ -3482,7 +3594,7 @@ async function renderTreeInFrame(
3482
3594
  : { entries: [], runtime: false }
3483
3595
  const runtimeMetadata =
3484
3596
  layoutRender.runtimeMetadata || Boolean(extra.runtimePageMetadata) || slotMetadata.runtime
3485
- const runtimeMetadataInBody = shouldRenderRuntimeMetadataInBody(options, runtimeMetadata)
3597
+ const metadataMayStream = shouldRenderRuntimeMetadataInBody(options, runtimeMetadata)
3486
3598
  let metadataNavigationHtml = ''
3487
3599
  let resolvedPageMetadata: Metadata
3488
3600
  const lateChunks: Promise<string>[] = []
@@ -3496,6 +3608,22 @@ async function renderTreeInFrame(
3496
3608
  // Captured explicitly: the page body already populated the render's cache() scope, and a deferred
3497
3609
  // generateMetadata must reuse it or its cache()-wrapped values drift from what the body rendered.
3498
3610
  const runInCacheScope = captureCacheScope()
3611
+ // WHERE the tags land. Next renders metadata inside a Suspense in the body and React hoists
3612
+ // whatever resolved before the shell flushed into the head - so FAST metadata reads as a head
3613
+ // render and only metadata that misses the flush stays below it (conformance "metadata body
3614
+ // placement matches Next" vs metadata-streaming "should delay the metadata render to body").
3615
+ // A router payload has no head to hoist into, so it always rides the body.
3616
+ const navMetadataRender =
3617
+ Boolean(options.nav?.soft) || options.request?.headers.get('rsc') === '1'
3618
+ const startedPageMetadata =
3619
+ metadataMayStream && !navMetadataRender && typeof pageMetadata === 'function'
3620
+ ? runInCacheScope(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata)))
3621
+ : undefined
3622
+ const runtimeMetadataInBody =
3623
+ metadataMayStream &&
3624
+ (navMetadataRender ||
3625
+ layoutRender.metadataMissedFlush ||
3626
+ (startedPageMetadata ? !(await settlesBeforeFlush(startedPageMetadata)) : false))
3499
3627
  const pendingPageMetadata =
3500
3628
  typeof pageMetadata === 'function' &&
3501
3629
  deferrableNavigationMetadata(options, runtimeMetadataInBody, stream)
@@ -3506,21 +3634,37 @@ async function renderTreeInFrame(
3506
3634
  try {
3507
3635
  resolvedPageMetadata = pendingPageMetadata
3508
3636
  ? {}
3509
- : typeof pageMetadata === 'function'
3510
- ? await pageMetadata(mergeMetadataEntries(layoutRender.metadata))
3511
- : pageMetadata
3637
+ : startedPageMetadata
3638
+ ? await startedPageMetadata
3639
+ : typeof pageMetadata === 'function'
3640
+ ? await pageMetadata(mergeMetadataEntries(layoutRender.metadata))
3641
+ : pageMetadata
3512
3642
  } catch (error) {
3513
3643
  if (options.partial?.mode === 'build' && isPostpone(error)) {
3514
3644
  options.partial.metadataDynamic = true
3515
3645
  resolvedPageMetadata = {}
3516
- } else if (!runtimeMetadataInBody) {
3646
+ } else if (
3647
+ options.request &&
3648
+ nextCompatEnabled(options.config) &&
3649
+ isNotFoundError(error) &&
3650
+ !isHtmlLimitedBotRequest(options)
3651
+ ) {
3652
+ // Streamed metadata resolves after the status is sent, so notFound() lands as a client
3653
+ // boundary on a 200. A bot's metadata BLOCKS, so its notFound() is a real 404
3654
+ // (metadata-streaming "blocking 404 response status when html limited bots").
3655
+ return renderNotFoundPage(options, stream, { status: 200, nextFlightText: true })
3656
+ } else if (!metadataMayStream) {
3657
+ // Blocking metadata (an html-limited bot, a build prerender): its control flow still owns
3658
+ // the whole response.
3517
3659
  throw error
3518
3660
  } else if (isNotFoundError(error)) {
3519
3661
  return renderNotFoundPage(options, stream, { status: 200, nextFlightText: true })
3520
- } else if (isRedirectError(error)) {
3662
+ } else if (isRedirectError(error) && runtimeMetadataInBody) {
3521
3663
  metadataNavigationHtml = metadataRedirectHtml(error.location, error.status)
3522
3664
  resolvedPageMetadata = {}
3523
3665
  } else {
3666
+ // Head-placed metadata carries no body chunk to hold the envelope; the document-level
3667
+ // handler emits it (200 + meta refresh, or a real 307 for a blocking bot render).
3524
3668
  throw error
3525
3669
  }
3526
3670
  }
@@ -3595,7 +3739,7 @@ async function renderTreeInFrame(
3595
3739
  stylesheets: [],
3596
3740
  })
3597
3741
  const iconLinks =
3598
- extra.runtimePageMetadata && pageMeta.icons !== undefined
3742
+ streamRuntimeMetadataInBody && extra.runtimePageMetadata && pageMeta.icons !== undefined
3599
3743
  ? [
3600
3744
  headLinks(
3601
3745
  {
@@ -3896,8 +4040,26 @@ function deferrableNavigationMetadata(
3896
4040
  return !getRouterProtocolExtensions().shellOnlyRequest(request, options.route)
3897
4041
  }
3898
4042
 
4043
+ /**
4044
+ * True when `promise` settles without waiting on I/O - a streaming server would still be building
4045
+ * the shell, so React would hoist the resolved tags into the head. A timer/network await loses the
4046
+ * race and the tags land in the body instead.
4047
+ */
4048
+ function settlesBeforeFlush(promise: Promise<unknown>): Promise<boolean> {
4049
+ return Promise.race([
4050
+ promise.then(
4051
+ () => true,
4052
+ () => true,
4053
+ ),
4054
+ new Promise<boolean>(resolve => setTimeout(() => resolve(false), 0)),
4055
+ ])
4056
+ }
4057
+
3899
4058
  function shouldRenderRuntimeMetadataInBody(options: RenderOptions, runtimeMetadata: boolean) {
3900
4059
  if (!runtimeMetadata) return false
4060
+ // Placement is a function of the USER AGENT alone (Next's shouldServeStreamingMetadata): a
4061
+ // document GET from a browser streams its metadata into the body exactly like an RSC
4062
+ // navigation does, and only an html-limited bot gets the blocking head render.
3901
4063
  if (!options.dev && options.route.mode !== 'dynamic') return false
3902
4064
  return Boolean(
3903
4065
  getActiveMetadataExtensions(options.config).shouldRenderMetadataInBody?.({
@@ -4031,8 +4193,10 @@ async function addDiscoveredClientReferences(
4031
4193
  const discovered = new Map<string, ClientReference>()
4032
4194
  collectClientReferences(tree, discovered)
4033
4195
  // Lazy mode: register ids only; ensureClientComponent imports the references
4034
- // the render actually reaches at emission time.
4035
- const lazy = Boolean(options.dev) && lazyClientReferencePreload()
4196
+ // the render actually reaches at emission time. A dev soft navigation is the
4197
+ // exception: compile the references in its rendered tree before shell emission
4198
+ // so application Suspense cannot expose a compile-only fallback.
4199
+ const lazy = Boolean(options.dev) && lazyClientReferencePreload() && !options.nav?.soft
4036
4200
  await Promise.all(
4037
4201
  [...discovered.values()].map(async reference => {
4038
4202
  const resolved = references.get(reference.id) ?? reference
@@ -4653,6 +4817,13 @@ function preloadClientReferences(options: RenderOptions, profile: RenderProfile
4653
4817
  markedReferences.clear()
4654
4818
  settledMarkedReferences.clear()
4655
4819
  }
4820
+ // A dev soft navigation must not join or start the whole route marking pass:
4821
+ // its response latency should scale with references in the rendered tree,
4822
+ // which addDiscoveredClientReferences compiles before shell emission. Reuse a
4823
+ // warm snapshot when available; otherwise discovery starts from an empty map.
4824
+ if (lazyClientReferencePreload() && options.nav?.soft) {
4825
+ return Promise.resolve(settledMarkedReferences.get(options.route.id) ?? new Map())
4826
+ }
4656
4827
  const cached = markedReferences.get(options.route.id)
4657
4828
  if (cached) return cached
4658
4829
  const marking = profileRenderStep(profile, 'preload client references', () =>
@@ -4699,6 +4870,7 @@ function ClientIsland(
4699
4870
  __pnextParamsScope?: RouteParamsSnapshot
4700
4871
  __pnextTemplate?: boolean
4701
4872
  __pnextSkipSsr?: boolean
4873
+ __pnextCsrFallback?: ComponentChildren
4702
4874
  },
4703
4875
  ) {
4704
4876
  const {
@@ -4709,6 +4881,7 @@ function ClientIsland(
4709
4881
  __pnextParamsScope,
4710
4882
  __pnextTemplate,
4711
4883
  __pnextSkipSsr,
4884
+ __pnextCsrFallback,
4712
4885
  children,
4713
4886
  ref: _ref,
4714
4887
  ...componentProps
@@ -4805,6 +4978,9 @@ function ClientIsland(
4805
4978
  island,
4806
4979
  ) as unknown as typeof island
4807
4980
  }
4981
+ if (__pnextCsrFallback !== undefined) {
4982
+ island = h(CsrBailoutContext.Provider, { value: true }, island) as unknown as typeof island
4983
+ }
4808
4984
  const fallbackChildren =
4809
4985
  staticChildren !== undefined && plainChildren === undefined
4810
4986
  ? h(
@@ -4823,7 +4999,12 @@ function ClientIsland(
4823
4999
  fallbackChildren !== undefined ? ([island, fallbackChildren] as ComponentChildren) : island,
4824
5000
  )
4825
5001
  }
4826
- return h(IslandBoundary, { islandProps, island, fallbackChildren })
5002
+ return h(IslandBoundary, {
5003
+ islandProps,
5004
+ island,
5005
+ fallbackChildren,
5006
+ csrFallback: __pnextCsrFallback,
5007
+ })
4827
5008
  }
4828
5009
 
4829
5010
  // Marks the island's static-children wrapper as rendered when the component
@@ -4854,6 +5035,7 @@ interface IslandBoundaryProps {
4854
5035
  islandProps: Record<string, unknown>
4855
5036
  island: ComponentChildren
4856
5037
  fallbackChildren?: ComponentChildren
5038
+ csrFallback?: ComponentChildren
4857
5039
  }
4858
5040
 
4859
5041
  // Per-island SSR error boundary. Normally renders the island's server output inside its
@@ -4877,6 +5059,13 @@ class IslandBoundary extends Component<IslandBoundaryProps, { failed: boolean; e
4877
5059
 
4878
5060
  render(props: IslandBoundaryProps, state: { failed: boolean; error: unknown }) {
4879
5061
  if (state.failed) {
5062
+ if (isPostpone(state.error) && props.csrFallback !== undefined) {
5063
+ return h(
5064
+ 'pnext-client',
5065
+ { ...props.islandProps, 'data-pnext-ssr-failed': '' },
5066
+ props.csrFallback,
5067
+ )
5068
+ }
4880
5069
  // Propagate control-flow signals to the page-render pipeline so a
4881
5070
  // client-component redirect() during (pre)render becomes a 307 instead of
4882
5071
  // being swallowed into a 200 `data-pnext-ssr-failed` placeholder.
@@ -5055,6 +5244,10 @@ async function resolveServerTree(node: unknown, state: ResolveState): Promise<Co
5055
5244
 
5056
5245
  if (isSuspenseVNode(node)) {
5057
5246
  if (state.partial) return resolvePartialSuspense(node, state, state.partial)
5247
+ if (isCsrBailoutPrerender()) {
5248
+ const fallback = await resolveServerTree(node.props.fallback, state)
5249
+ return resolveServerTree(node.props.children, { ...state, csrBailoutFallback: fallback })
5250
+ }
5058
5251
  return resolveServerTree(node.props.children, state)
5059
5252
  }
5060
5253
 
@@ -6238,6 +6431,7 @@ function clientIslandVNode(
6238
6431
  __pnextParamsScope: currentParamsSnapshot(),
6239
6432
  __pnextSkipSsr:
6240
6433
  state.partial?.mode === 'build' && clientReferenceRequiresRuntime(resolvedReference),
6434
+ __pnextCsrFallback: state.csrBailoutFallback,
6241
6435
  },
6242
6436
  children,
6243
6437
  )
@@ -6577,6 +6771,7 @@ async function readRenderModuleMetadata(
6577
6771
  options.partial.metadataDynamic = true
6578
6772
  return undefined
6579
6773
  }
6774
+ noteMetadataRedirect(error)
6580
6775
  throw error
6581
6776
  }
6582
6777
  }
@@ -6747,13 +6942,25 @@ async function renderSuspendingStreamShell(
6747
6942
  // keys off this element instead. `display:contents` keeps it byte-neutral in
6748
6943
  // layout, and it is adopted as the island's static children on hydration.
6749
6944
  function anchorInlineSuspenseHoles(html: string): string {
6750
- return html
6751
- .replace(
6752
- /<!--\$s:([^>]+?)-->/g,
6753
- (_match, id: string) =>
6754
- `<pnext-hole data-pnext-hole="${escapeHtml(id)}" style="display:contents">`,
6755
- )
6756
- .replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
6945
+ return (
6946
+ html
6947
+ .replace(
6948
+ /<!--\$s:([^>]+?)-->/g,
6949
+ (_match, id: string) =>
6950
+ `<pnext-hole data-pnext-hole="${escapeHtml(id)}" style="display:contents">`,
6951
+ )
6952
+ .replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
6953
+ // A loading.js fallback leads with its depth marker (markInlineLoadingFallback): lift it
6954
+ // onto the hole, which is the only wire form the runtime can read attributes from.
6955
+ .replace(
6956
+ new RegExp(
6957
+ `(<pnext-hole data-pnext-hole="[^"]*")( style="display:contents">)<${LOADING_DEPTH_TAG} ${LOADING_DEPTH_ATTRIBUTE}="(\\d+)"></${LOADING_DEPTH_TAG}>`,
6958
+ 'g',
6959
+ ),
6960
+ (_match, open: string, close: string, depth: string) =>
6961
+ `${open} ${LOADING_DEPTH_ATTRIBUTE}="${depth}"${close}`,
6962
+ )
6963
+ )
6757
6964
  }
6758
6965
 
6759
6966
  export function clientSuspenseRefreshScript(nonce: string | undefined) {
@@ -6794,7 +7001,11 @@ function unwrapUnusedPageSlot(html: string, mode: PageSlotMarkerMode): string {
6794
7001
  return html.replace(/<!--pnext-page:[^>]*-->/, '<!--pnext-page:-->')
6795
7002
  }
6796
7003
  const slot = pageSlotSpan(html)
6797
- return slot ? html.slice(0, slot.start) + slot.children + html.slice(slot.end) : html
7004
+ if (!slot) return html
7005
+ // `keep` leaves the empty anchors a soft navigation grafts a kept layout's next page between.
7006
+ const children =
7007
+ mode === 'keep' ? `<!--pnext-page:-->${slot.children}<!--/pnext-page-->` : slot.children
7008
+ return html.slice(0, slot.start) + children + html.slice(slot.end)
6798
7009
  }
6799
7010
 
6800
7011
  function serializeNeutralClientIslands(html: string) {
@@ -6814,6 +7025,12 @@ function serializeNeutralClientIslands(html: string) {
6814
7025
  (_tag, attributes: string) => `<!--pnext-client:${encodeIslandMarker(attributes)}-->`,
6815
7026
  )
6816
7027
  .replace(/<\/pnext-client>/g, '<!--/pnext-client-->')
7028
+ .replace(
7029
+ /<pnext-static-children\b([^>]*)>/g,
7030
+ (_tag, attributes: string) =>
7031
+ `<!--pnext-static-children:${encodeIslandMarker(attributes)}-->`,
7032
+ )
7033
+ .replace(/<\/pnext-static-children>/g, '<!--/pnext-static-children-->')
6817
7034
  })
6818
7035
  .join('')
6819
7036
  return neutralizePageSlot(islands)
@@ -6912,7 +7129,10 @@ export function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'ma
6912
7129
  // server `<p>`, say), by climbing out of the start marker's parent instead of
6913
7130
  // giving up on a sibling-only scan. Mirrors islandMarkerRange in router.ts.
6914
7131
  const range = `let a=[],n=null,y=s;for(;;){n=y.nextSibling;while(!n){const p=y.parentNode;if(!p||p===document.documentElement||p===document)break;y=p;n=y.nextSibling}if(!n)break;if(n.nodeType===8&&n.data==='/'+k)break;a.push(n);y=n}if(!n||n.nodeType!==8||n.data!=='/'+k)continue;const f=a[0];if(f&&f.parentNode!==s.parentNode){f.parentNode.insertBefore(e,f);s.remove()}else{s.replaceWith(e)}for(const x of a)e.append(x);n.remove()`
6915
- return `(()=>{window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__=r=>{const c=[];const w=document.createTreeWalker(r,NodeFilter.SHOW_COMMENT);for(;w.nextNode();)c.push(w.currentNode);for(const s of c.reverse()){if(s.data==='$s'||s.data==='/$s'){s.remove();continue}const m=/^(pnext-client(?:-after)?|pnext-page):([^>]*)$/.exec(s.data);if(!m||!s.parentNode)continue;${skipPage}const k=m[1]==='pnext-client-after'?'pnext-client':m[1],d=document.createElement('div'),q=k==='pnext-page'?'div':'pnext-client';d.innerHTML='<'+q+' '+m[2]+'></'+q+'>';const e=d.firstElementChild;if(m[1]==='pnext-client-after'){let a=s.previousSibling;while(a&&!(a.nodeType===8&&!a.data.trim()))a=a.previousSibling;if(!a)continue;let n=a.nextSibling;while(n&&n!==s){const x=n;n=n.nextSibling;e.append(x)}s.replaceWith(e);continue}${range}}};window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__(document)})()`
7132
+ // This script runs after the server shell markup and before any client entry can hydrate it.
7133
+ // Capture once, then materialize marker ranges. A promotion script that raced ahead keeps its
7134
+ // stricter pre-promotion snapshot through the same first-writer-wins guard.
7135
+ return `(function(){let W=window;W.__PNEXT_SHELL_HTML__||="<!doctype html>"+document.documentElement.outerHTML;Object.defineProperty(W,"__PNEXT_SHELL_HTML__",{writable:false});(W.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__=r=>{let c=[],w=document.createTreeWalker(r,128);for(;w.nextNode();)c.push(w.currentNode);for(let s of c.reverse()){if(s.data==='$s'||s.data==='/$s'){s.remove();continue}let m=/^(pnext-client(?:-after)?|pnext-static-children|pnext-page):([^>]*)$/.exec(s.data);if(!m||!s.parentNode)continue;${skipPage}let k=m[1]==='pnext-client-after'?'pnext-client':m[1],d=document.createElement('div'),q=k==='pnext-page'?'div':k;d.innerHTML='<'+q+' '+m[2]+'></'+q+'>';let e=d.firstElementChild;if(m[1]==='pnext-client-after'){let a=s.previousSibling;while(a&&!(a.nodeType===8&&!a.data.trim()))a=a.previousSibling;if(!a)continue;let n=a.nextSibling;while(n&&n!==s){let x=n;n=n.nextSibling;e.append(x)}s.replaceWith(e);continue}${range}}})(document)})()`
6916
7136
  }
6917
7137
 
6918
7138
  // notFound() thrown inside a streamed Suspense boundary: render the nearest not-found convention
@@ -7039,9 +7259,9 @@ function streamErrorDocumentScript(
7039
7259
  // resolve to - and delete - whatever innocent node now precedes the script.
7040
7260
  const PROMO_RUNTIME =
7041
7261
  'function(){' +
7042
- // Stash the PRE-PROMOTION shell exactly once, before any streamed chunk has
7043
- // replaced its fallback. This runtime is constructed lazily by the FIRST
7044
- // promotion script, so the parser has seen the shell and nothing beyond it.
7262
+ // Stash the PRE-PROMOTION shell before any streamed chunk has replaced its fallback. The document
7263
+ // bootstrap normally wins first; when a fast continuation wins this race instead, retain its
7264
+ // stricter pre-promotion copy and let the document bootstrap preserve it.
7045
7265
  //
7046
7266
  // A hard load has no other way back to its static stage: the router seeds its
7047
7267
  // caches from the LIVE document at the load event, by which time every
@@ -7049,7 +7269,7 @@ const PROMO_RUNTIME =
7049
7269
  // indistinguishable from a complete static prerender. Recording THAT as the
7050
7270
  // route's static stage would hand a later navigation resolved dynamic content;
7051
7271
  // recording nothing (today) leaves a PPR route with no cached static stage at
7052
- // all after a hard load. See client/router/index.ts preHydrationShell.
7272
+ // all after a hard load. See client/router/runtime.ts preHydrationShell.
7053
7273
  'try{window.__PNEXT_SHELL_HTML__="<!doctype html>"+document.documentElement.outerHTML;}catch(e){}' +
7054
7274
  'var P={};' +
7055
7275
  // Group a node's children by the `<!--$ps:k-->…<!--/$ps:k-->` slot markers the
@@ -7064,6 +7284,10 @@ const PROMO_RUNTIME =
7064
7284
  'if(!t){delete P[i];return true;}' +
7065
7285
  "var e=document.querySelector('pnext-suspense[data-pnext-suspense=\"'+i+'\"]');" +
7066
7286
  'if(!e){P[i]=1;return false;}' +
7287
+ // A boundary inside a client island: this promotion rewrites DOM the island's mounted
7288
+ // preact tree adopted at hydration, which still names the fallback. Mark the root so the
7289
+ // next graft rebuilds it instead of diffing against a node that has left the document.
7290
+ 'var ci=e.closest?e.closest("pnext-client[data-pnext-client]"):null;if(ci)ci.__pnextStreamGrafted=1;' +
7067
7291
  'var f=document.createDocumentFragment();' +
7068
7292
  // Slot-granular chunk: weave the shell template's build-time slots and the
7069
7293
  // chunk's request-time slots back into their original order, so a static
@@ -7106,6 +7330,7 @@ async function applyLayouts(
7106
7330
  let tree = children
7107
7331
  const metadata: MetadataEntry[] = []
7108
7332
  let runtimeMetadata = false
7333
+ let metadataMissedFlush = false
7109
7334
  const viewport: Viewport[] = []
7110
7335
  const layoutProps = createPageProps(options)
7111
7336
  const layouts: LoadedLayout[] = []
@@ -7225,7 +7450,7 @@ async function applyLayouts(
7225
7450
  const layoutModuleRecord = layoutModule as Record<string, unknown>
7226
7451
  runtimeMetadata ||= Boolean(metadataExtensions.hasRuntimeMetadata?.(layoutModuleRecord))
7227
7452
  const prebuiltLayoutMetadata = options.staticModuleMetadata?.[layoutPath]
7228
- const layoutMetadata = await readRenderModuleMetadata(
7453
+ const layoutMetadataPromise = readRenderModuleMetadata(
7229
7454
  options,
7230
7455
  layoutModuleRecord,
7231
7456
  layoutProps,
@@ -7233,6 +7458,10 @@ async function applyLayouts(
7233
7458
  layoutPath,
7234
7459
  prebuiltLayoutMetadata?.metadata,
7235
7460
  )
7461
+ // Same head-vs-body race the page metadata runs (see renderTreeInFrame): a layout's slow
7462
+ // generateMetadata streams into the body too (metadata-streaming-parallel-routes).
7463
+ metadataMissedFlush ||= !(await settlesBeforeFlush(layoutMetadataPromise))
7464
+ const layoutMetadata = await layoutMetadataPromise
7236
7465
  if (layoutMetadata) {
7237
7466
  const dir = dirname(layoutPath)
7238
7467
  metadata.push({
@@ -7291,7 +7520,7 @@ async function applyLayouts(
7291
7520
  wrapped = h(
7292
7521
  Suspense,
7293
7522
  {
7294
- fallback,
7523
+ fallback: markInlineLoadingFallback(fallback, depth),
7295
7524
  ...(through ? { [LOADING_THROUGH_PROP]: true } : {}),
7296
7525
  ...(depth === undefined ? {} : { [LOADING_DEPTH_PROP]: depth }),
7297
7526
  },
@@ -7450,6 +7679,7 @@ async function applyLayouts(
7450
7679
  tree,
7451
7680
  metadata,
7452
7681
  runtimeMetadata,
7682
+ metadataMissedFlush,
7453
7683
  slotMatches: slotContext.matches,
7454
7684
  viewport,
7455
7685
  documentLayoutFile,
@@ -159,7 +159,6 @@ export function flushWorkUnitOnClose(
159
159
  signal?: AbortSignal,
160
160
  ): Response {
161
161
  if (!unit) return response
162
- if (signal) signal.addEventListener('abort', () => flushWorkUnit(unit), { once: true })
163
162
  if (!response.body) {
164
163
  flushWorkUnit(unit)
165
164
  return response
@@ -172,16 +171,38 @@ export function flushWorkUnitOnClose(
172
171
  flushWorkUnit(unit)
173
172
  return response
174
173
  }
175
- const observed = response.body.pipeThrough(
176
- new TransformStream({
177
- transform(chunk, controller) {
178
- controller.enqueue(chunk)
179
- },
180
- flush() {
181
- flushWorkUnit(unit)
182
- },
183
- }),
184
- )
174
+ const reader = response.body.getReader()
175
+ const settle = () => {
176
+ signal?.removeEventListener('abort', settle)
177
+ flushWorkUnit(unit)
178
+ }
179
+ if (signal?.aborted) settle()
180
+ else signal?.addEventListener('abort', settle, { once: true })
181
+ // Bun cancels a response body when its client disconnects. A TransformStream
182
+ // observes normal EOF through flush(), but cancellation bypasses flush and
183
+ // only propagates upstream. Own the outer stream so both terminal paths
184
+ // settle the request work unit, even when Request.signal is not the original
185
+ // socket-owned signal after request rewriting.
186
+ const observed = new ReadableStream<Uint8Array>({
187
+ async pull(controller) {
188
+ try {
189
+ const chunk = await reader.read()
190
+ if (chunk.done) {
191
+ settle()
192
+ controller.close()
193
+ } else {
194
+ controller.enqueue(chunk.value)
195
+ }
196
+ } catch (error) {
197
+ settle()
198
+ controller.error(error)
199
+ }
200
+ },
201
+ cancel(reason) {
202
+ settle()
203
+ return reader.cancel(reason)
204
+ },
205
+ })
185
206
  return new Response(observed, {
186
207
  status: response.status,
187
208
  statusText: response.statusText,