@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.
- package/bin/pnext +1 -1
- package/package.json +2 -2
- package/reference/data/bench.json +398 -223
- package/reference/getting-started.md +1 -1
- package/reference/navigation.md +2 -2
- package/src/api/client-navigation.ts +7 -5
- package/src/cli/adapters/vercel-warm.ts +120 -28
- package/src/cli/build.ts +56 -15
- package/src/cli/create.ts +1 -1
- package/src/cli/dev.ts +2 -1
- package/src/cli/index.ts +146 -10
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/serve/pipeline.ts +23 -6
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/start.ts +2 -1
- package/src/cli/typegen.ts +5 -2
- package/src/client/build.ts +217 -58
- package/src/client/chunk-fold.ts +7 -1
- package/src/client/entry.ts +44 -7
- package/src/client/router/runtime.ts +1099 -236
- package/src/client/router/types.ts +15 -0
- package/src/compat/actions/client-plugin.ts +10 -0
- package/src/compat/actions/rewrite.ts +1 -0
- package/src/compat/bundler/optimize-package-imports.ts +375 -28
- package/src/compat/client/navigation-scroll.ts +29 -3
- package/src/compat/client/optimistic-routing.ts +2 -2
- package/src/compat/client/segment-cache-policy.ts +4 -18
- package/src/compat/client/segment-cache.ts +104 -0
- package/src/compat/client/segment-prefetch.ts +6 -6
- package/src/compat/next/config-loader.ts +6 -4
- package/src/compat/next/navigation.ts +16 -2
- package/src/compat/pages/router.ts +102 -9
- package/src/compat/protocol.ts +10 -1
- package/src/compat/register/actions.ts +2 -0
- package/src/compat/register/bundler.ts +6 -6
- package/src/compat/register/routing.ts +3 -4
- package/src/compat/tsconfig-defaults.ts +3 -2
- package/src/config.ts +3 -3
- package/src/css/postcss.ts +450 -17
- package/src/dev/client-actions.ts +2 -0
- package/src/dev/server.ts +87 -14
- package/src/render/island-context.ts +3 -0
- package/src/render/ppr.ts +14 -0
- package/src/render/renderer.ts +273 -43
- package/src/request/context.ts +32 -11
- package/src/resolve/scan-facts.ts +23 -4
- package/src/routing/proxy.ts +29 -25
- package/src/routing/routes.ts +1 -1
- package/src/runtime/loader.ts +47 -1
- package/src/runtime/modules.ts +546 -65
- package/src/runtime/vendor-build.ts +109 -22
- package/src/runtime/vendor.ts +7 -3
- package/src/utils/ansi.ts +3 -1
- package/src/utils/fs.ts +17 -3
package/src/render/renderer.ts
CHANGED
|
@@ -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
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
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))
|
|
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
|
|
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
|
-
:
|
|
3510
|
-
? await
|
|
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 (
|
|
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
|
-
|
|
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, {
|
|
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
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7043
|
-
//
|
|
7044
|
-
// promotion
|
|
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/
|
|
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
|
|
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,
|
package/src/request/context.ts
CHANGED
|
@@ -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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,
|