Skip to content

Commit 02103fe

Browse files
authored
onHeaders updates for App Pages (#58410)
This serves to address a few problems as it relates to headers handling within the App Page render pipeline. 1. During static generation, we should not write to the response directly as the revalidation for static responses is performed out of band from the actual request/response as a stale version can be served early. This is partially addressed by modifying the `onHeaders` to only write when there it is not during static generation, but long term I'd hope to see that we can instead pass a immutable request and `null` for the response to help indicate to the render pipeline that it should instead persist the data into the `RenderResult` objects. Alternatively, the response could use a mocked version that would then supersede the `RenderResult` and be used instead. 2. The types for rendering App Pages were merged incorrectly, so this additionally restructures the way that we initialize those renderers such that only the options that are applicable to each renderer is passed down.
1 parent 2b02889 commit 02103fe

File tree

8 files changed

+304
-101
lines changed

8 files changed

+304
-101
lines changed

packages/next/src/export/routes/app-page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export async function exportAppPage(
184184
)
185185
}
186186

187-
const headers = { ...metadata.extraHeaders }
187+
const headers = { ...metadata.headers }
188188

189189
if (fetchTags) {
190190
headers[NEXT_CACHE_TAGS_HEADER] = fetchTags

packages/next/src/lib/recursive-delete.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import type { Dirent } from 'fs'
22
import { promises } from 'fs'
33
import { join, isAbsolute, dirname } from 'path'
44
import isError from './is-error'
5-
6-
const sleep = (timeout: number) =>
7-
new Promise((resolve) => setTimeout(resolve, timeout))
5+
import { wait } from './wait'
86

97
const unlinkPath = async (p: string, isDir = false, t = 1): Promise<void> => {
108
try {
@@ -22,7 +20,7 @@ const unlinkPath = async (p: string, isDir = false, t = 1): Promise<void> => {
2220
code === 'EMFILE') &&
2321
t < 3
2422
) {
25-
await sleep(t * 100)
23+
await wait(t * 100)
2624
return unlinkPath(p, isDir, t++)
2725
}
2826

packages/next/src/server/app-render/action-handler.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import type {
66
} from 'http'
77
import type { WebNextRequest } from '../base-http/web'
88
import type { SizeLimit } from '../../../types'
9+
import type { RequestStore } from '../../client/components/request-async-storage.external'
10+
import type { AppRenderContext, GenerateFlight } from './app-render'
11+
import type { AppPageModule } from '../../server/future/route-modules/app-page/module'
912

1013
import {
1114
ACTION,
@@ -20,7 +23,6 @@ import {
2023
import RenderResult from '../render-result'
2124
import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external'
2225
import { FlightRenderResult } from './flight-render-result'
23-
import type { ActionAsyncStorage } from '../../client/components/action-async-storage.external'
2426
import {
2527
filterReqHeaders,
2628
actionsForbiddenHeaders,
@@ -30,12 +32,10 @@ import {
3032
getModifiedCookieValues,
3133
} from '../web/spec-extension/adapters/request-cookies'
3234

33-
import type { RequestStore } from '../../client/components/request-async-storage.external'
3435
import {
3536
NEXT_CACHE_REVALIDATED_TAGS_HEADER,
3637
NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
3738
} from '../../lib/constants'
38-
import type { AppRenderContext, GenerateFlight } from './app-render'
3939

4040
function formDataFromSearchQueryString(query: string) {
4141
const searchParams = new URLSearchParams(query)
@@ -244,7 +244,7 @@ export async function handleAction({
244244
}: {
245245
req: IncomingMessage
246246
res: ServerResponse
247-
ComponentMod: any
247+
ComponentMod: AppPageModule
248248
serverModuleMap: {
249249
[id: string]: {
250250
id: string
@@ -288,6 +288,12 @@ export async function handleAction({
288288
return
289289
}
290290

291+
if (staticGenerationStore.isStaticGeneration) {
292+
throw new Error(
293+
"Invariant: server actions can't be handled during static rendering"
294+
)
295+
}
296+
291297
const originDomain =
292298
typeof req.headers['origin'] === 'string'
293299
? new URL(req.headers['origin']).host
@@ -373,9 +379,7 @@ export async function handleAction({
373379
)
374380
let bound = []
375381

376-
const { actionAsyncStorage } = ComponentMod as {
377-
actionAsyncStorage: ActionAsyncStorage
378-
}
382+
const { actionAsyncStorage } = ComponentMod
379383

380384
let actionResult: RenderResult | undefined
381385
let formState: any | undefined

packages/next/src/server/app-render/app-render.tsx

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { getAssetQueryString } from './get-asset-query-string'
7373
import { setReferenceManifestsSingleton } from './action-encryption-utils'
7474
import { createStaticRenderer } from './static/static-renderer'
7575
import { MissingPostponeDataError } from './is-missing-postpone-error'
76+
import { DetachedPromise } from '../../lib/detached-promise'
7677

7778
export type GetDynamicParamFromSegment = (
7879
// [slug] / [[slug]] / [...slug]
@@ -654,7 +655,13 @@ async function renderToHTMLOrFlightImpl(
654655
createServerInsertedHTML()
655656

656657
getTracer().getRootSpanAttributes()?.set('next.route', pagePath)
657-
const bodyResult = getTracer().wrap(
658+
659+
// Create a promise that will help us signal when the headers have been
660+
// written to the metadata for static generation as they aren't written to the
661+
// response directly.
662+
const onHeadersFinished = new DetachedPromise<void>()
663+
664+
const renderToStream = getTracer().wrap(
658665
AppRenderSpan.getBodyResult,
659666
{
660667
spanName: `render route (app) ${pagePath}`,
@@ -703,21 +710,13 @@ async function renderToHTMLOrFlightImpl(
703710
nonce
704711
)
705712

706-
const renderer = createStaticRenderer({
707-
ppr: renderOpts.experimental.ppr,
708-
isStaticGeneration: staticGenerationStore.isStaticGeneration,
709-
postponed: renderOpts.postponed
710-
? JSON.parse(renderOpts.postponed)
711-
: null,
712-
})
713-
714713
const ServerComponentsRenderer = createServerComponentsRenderer(tree, {
715714
ctx,
716715
preinitScripts,
717716
options: serverComponentsRenderOpts,
718717
})
719718

720-
const content = (
719+
const children = (
721720
<HeadManagerContext.Provider
722721
value={{
723722
appDir: true,
@@ -736,34 +735,51 @@ async function renderToHTMLOrFlightImpl(
736735
hasPostponed,
737736
})
738737

739-
function onHeaders(headers: Headers): void {
740-
// Copy headers created by React into the response object.
741-
headers.forEach((value: string, key: string) => {
742-
res.appendHeader(key, value)
743-
if (!extraRenderResultMeta.extraHeaders) {
744-
extraRenderResultMeta.extraHeaders = {}
745-
}
746-
extraRenderResultMeta.extraHeaders[key] = value
747-
})
748-
}
749-
750-
try {
751-
const renderStream = await renderer.render(content, {
738+
const renderer = createStaticRenderer({
739+
ppr: renderOpts.experimental.ppr,
740+
isStaticGeneration,
741+
// If provided, the postpone state should be parsed as JSON so it can be
742+
// provided to React.
743+
postponed: renderOpts.postponed
744+
? JSON.parse(renderOpts.postponed)
745+
: null,
746+
streamOptions: {
752747
onError: htmlRendererErrorHandler,
753-
onHeaders: onHeaders,
748+
onHeaders: (headers: Headers) => {
749+
// If this is during static generation, we shouldn't write to the
750+
// headers object directly, instead we should add to the render
751+
// result.
752+
if (isStaticGeneration) {
753+
headers.forEach((value, key) => {
754+
extraRenderResultMeta.headers ??= {}
755+
extraRenderResultMeta.headers[key] = value
756+
})
757+
758+
// Resolve the promise to continue the stream.
759+
onHeadersFinished.resolve()
760+
} else {
761+
headers.forEach((value, key) => {
762+
res.appendHeader(key, value)
763+
})
764+
}
765+
},
754766
maxHeadersLength: 600,
755767
nonce,
756768
bootstrapScripts: [bootstrapScript],
757769
formState,
758-
})
770+
},
771+
})
759772

760-
const { stream, postponed } = renderStream
773+
try {
774+
let { stream, postponed } = await renderer.render(children)
761775

776+
// If the stream was postponed, we need to add the result to the
777+
// metadata so that it can be resumed later.
762778
if (postponed) {
763779
extraRenderResultMeta.postponed = JSON.stringify(postponed)
764780

765-
// If this render generated a postponed state, we don't want to add
766-
// any other data to the response.
781+
// We don't need to "continue" this stream now as it's continued when
782+
// we resume the stream.
767783
return stream
768784
}
769785

@@ -786,10 +802,10 @@ async function renderToHTMLOrFlightImpl(
786802
}
787803

788804
if (renderOpts.postponed) {
789-
return continuePostponedFizzStream(stream, options)
805+
return await continuePostponedFizzStream(stream, options)
790806
}
791807

792-
return continueFizzStream(stream, options)
808+
return await continueFizzStream(stream, options)
793809
} catch (err: any) {
794810
if (
795811
err.code === 'NEXT_STATIC_GEN_BAILOUT' ||
@@ -967,8 +983,8 @@ async function renderToHTMLOrFlightImpl(
967983
ComponentMod,
968984
serverModuleMap,
969985
generateFlight,
970-
staticGenerationStore: staticGenerationStore,
971-
requestStore: requestStore,
986+
staticGenerationStore,
987+
requestStore,
972988
serverActions,
973989
ctx,
974990
})
@@ -978,7 +994,7 @@ async function renderToHTMLOrFlightImpl(
978994
if (actionRequestResult.type === 'not-found') {
979995
const notFoundLoaderTree = createNotFoundLoaderTree(loaderTree)
980996
return new RenderResult(
981-
await bodyResult({
997+
await renderToStream({
982998
asNotFound: true,
983999
tree: notFoundLoaderTree,
9841000
formState,
@@ -996,15 +1012,20 @@ async function renderToHTMLOrFlightImpl(
9961012
}
9971013

9981014
const renderResult = new RenderResult(
999-
await bodyResult({
1015+
await renderToStream({
10001016
asNotFound: isNotFoundPath,
10011017
tree: loaderTree,
10021018
formState,
10031019
}),
10041020
{
10051021
...extraRenderResultMeta,
1022+
// Wait for and collect the flight payload data if we don't have it
1023+
// already.
10061024
pageData: await stringifiedFlightPayloadPromise,
1007-
waitUntil: Promise.all(staticGenerationStore.pendingRevalidates || []),
1025+
// If we have pending revalidates, wait until they are all resolved.
1026+
waitUntil: staticGenerationStore.pendingRevalidates
1027+
? Promise.all(staticGenerationStore.pendingRevalidates)
1028+
: undefined,
10081029
}
10091030
)
10101031

@@ -1015,8 +1036,30 @@ async function renderToHTMLOrFlightImpl(
10151036
})
10161037

10171038
if (staticGenerationStore.isStaticGeneration) {
1039+
// Collect the entire render result to a string (by streaming it to a
1040+
// string).
10181041
const htmlResult = await renderResult.toUnchunkedString(true)
10191042

1043+
// Timeout after 1.5 seconds for the headers to write. If it takes
1044+
// longer than this it's more likely that the stream has stalled and
1045+
// there is a React bug. The headers will then be updated in the render
1046+
// result below when the metadata is re-added to the new render result.
1047+
const onTimeout = new DetachedPromise<never>()
1048+
const timeout = setTimeout(() => {
1049+
onTimeout.reject(
1050+
new Error(
1051+
'Timeout waiting for headers to be emitted, this is a bug in Next.js'
1052+
)
1053+
)
1054+
}, 1500)
1055+
1056+
// Race against the timeout and the headers being written.
1057+
await Promise.race([onHeadersFinished.promise, onTimeout.promise])
1058+
1059+
// It got here, which means it did not reject, so clear the timeout to avoid
1060+
// it from rejecting again (which is a no-op anyways).
1061+
clearTimeout(timeout)
1062+
10201063
if (
10211064
// if PPR is enabled
10221065
renderOpts.experimental.ppr &&

0 commit comments

Comments
 (0)