Skip to content

Commit d60bb1b

Browse files
authored
Backport: Use provided waitUntil for pending revalidates (#74164) (#74573)
This backports #74164 to leverage built-in waitUntil if available instead of using the approach that keeps the stream open until the waitUntil promise resolves. x-ref: [slack thread](https://vercel.slack.com/archives/C02K2HCH5V4/p1736211642221149?thread_ts=1734707275.666089&cid=C02K2HCH5V4)
1 parent a85f441 commit d60bb1b

File tree

7 files changed

+112
-13
lines changed

7 files changed

+112
-13
lines changed

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

+28-4
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export type AppRenderContext = AppRenderBaseContext & {
148148
serverComponentsErrorHandler: ErrorHandler
149149
isNotFoundPath: boolean
150150
res: ServerResponse
151+
builtInWaitUntil: RenderOpts['builtInWaitUntil']
151152
}
152153

153154
function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
@@ -387,12 +388,23 @@ async function generateFlight(
387388
ctx.staticGenerationStore.pendingRevalidates ||
388389
ctx.staticGenerationStore.revalidatedTags
389390
) {
390-
resultOptions.waitUntil = Promise.all([
391+
const pendingPromise = Promise.all([
391392
ctx.staticGenerationStore.incrementalCache?.revalidateTag(
392393
ctx.staticGenerationStore.revalidatedTags || []
393394
),
394395
...Object.values(ctx.staticGenerationStore.pendingRevalidates || {}),
395-
])
396+
]).finally(() => {
397+
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
398+
console.log('pending revalidates promise finished for:', urlPathname)
399+
}
400+
})
401+
402+
// use built-in waitUntil if available
403+
if (ctx.builtInWaitUntil) {
404+
ctx.builtInWaitUntil(pendingPromise)
405+
} else {
406+
resultOptions.waitUntil = pendingPromise
407+
}
396408
}
397409

398410
return new FlightRenderResult(flightReadableStream, resultOptions)
@@ -848,6 +860,7 @@ async function renderToHTMLOrFlightImpl(
848860

849861
const ctx: AppRenderContext = {
850862
...baseCtx,
863+
builtInWaitUntil: renderOpts.builtInWaitUntil,
851864
getDynamicParamFromSegment,
852865
query,
853866
isPrefetch: isPrefetchRSCRequest,
@@ -1401,12 +1414,23 @@ async function renderToHTMLOrFlightImpl(
14011414
staticGenerationStore.pendingRevalidates ||
14021415
staticGenerationStore.revalidatedTags
14031416
) {
1404-
options.waitUntil = Promise.all([
1417+
const pendingPromise = Promise.all([
14051418
staticGenerationStore.incrementalCache?.revalidateTag(
14061419
staticGenerationStore.revalidatedTags || []
14071420
),
14081421
...Object.values(staticGenerationStore.pendingRevalidates || {}),
1409-
])
1422+
]).finally(() => {
1423+
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
1424+
console.log('pending revalidates promise finished for:', req.url)
1425+
}
1426+
})
1427+
1428+
// use built-in waitUntil if available
1429+
if (renderOpts.builtInWaitUntil) {
1430+
renderOpts.builtInWaitUntil(pendingPromise)
1431+
} else {
1432+
options.waitUntil = pendingPromise
1433+
}
14101434
}
14111435

14121436
addImplicitTags(staticGenerationStore)

packages/next/src/server/app-render/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-context.shar
1010
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
1111

1212
import s from 'next/dist/compiled/superstruct'
13+
import type { WaitUntil } from '../lib/builtin-request-context'
1314

1415
export type DynamicParamTypes =
1516
| 'catchall'
@@ -170,6 +171,8 @@ export interface RenderOptsPartial {
170171
*/
171172
isDebugPPRSkeleton?: boolean
172173
isStaticGeneration?: boolean
174+
175+
builtInWaitUntil?: WaitUntil
173176
}
174177

175178
export type RenderOpts = LoadComponentsReturnType<AppPageModule> &

packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type StaticGenerationContext = {
4242
| 'nextExport'
4343
| 'isDraftMode'
4444
| 'isDebugPPRSkeleton'
45+
| 'builtInWaitUntil'
4546
>
4647
}
4748

packages/next/src/server/base-server.ts

+18
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
normalizeRepeatedSlashes,
2121
MissingStaticPage,
2222
} from '../shared/lib/utils'
23+
import {
24+
getBuiltinRequestContext,
25+
type WaitUntil,
26+
} from './lib/builtin-request-context'
2327
import type { PreviewData } from 'next/types'
2428
import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
2529
import type { BaseNextRequest, BaseNextResponse } from './base-http'
@@ -1596,6 +1600,18 @@ export default abstract class Server<ServerOptions extends Options = Options> {
15961600
)
15971601
}
15981602

1603+
protected getWaitUntil(): WaitUntil | undefined {
1604+
const builtinRequestContext = getBuiltinRequestContext()
1605+
if (builtinRequestContext) {
1606+
// the platform provided a request context.
1607+
// use the `waitUntil` from there, whether actually present or not --
1608+
// if not present, `after` will error.
1609+
1610+
// NOTE: if we're in an edge runtime sandbox, this context will be used to forward the outer waitUntil.
1611+
return builtinRequestContext.waitUntil
1612+
}
1613+
}
1614+
15991615
private async renderImpl(
16001616
req: BaseNextRequest,
16011617
res: BaseNextResponse,
@@ -2198,6 +2214,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
21982214
isDraftMode: isPreviewMode,
21992215
isServerAction,
22002216
postponed,
2217+
builtInWaitUntil: this.getWaitUntil(),
22012218
}
22022219

22032220
if (isDebugPPRSkeleton) {
@@ -2225,6 +2242,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
22252242
supportsDynamicResponse,
22262243
incrementalCache,
22272244
isRevalidate: isSSG,
2245+
builtInWaitUntil: this.getWaitUntil(),
22282246
},
22292247
}
22302248

packages/next/src/server/future/route-modules/app-route/module.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,28 @@ export class AppRouteRouteModule extends RouteModule<
383383
context.renderOpts.fetchMetrics =
384384
staticGenerationStore.fetchMetrics
385385

386-
context.renderOpts.waitUntil = Promise.all([
386+
const pendingPromise = Promise.all([
387387
staticGenerationStore.incrementalCache?.revalidateTag(
388388
staticGenerationStore.revalidatedTags || []
389389
),
390390
...Object.values(
391391
staticGenerationStore.pendingRevalidates || {}
392392
),
393-
])
393+
]).finally(() => {
394+
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
395+
console.log(
396+
'pending revalidates promise finished for:',
397+
rawRequest.url.toString()
398+
)
399+
}
400+
})
401+
402+
// use built-in waitUntil if available
403+
if (context.renderOpts.builtInWaitUntil) {
404+
context.renderOpts.builtInWaitUntil(pendingPromise)
405+
} else {
406+
context.renderOpts.waitUntil = pendingPromise
407+
}
394408

395409
addImplicitTags(staticGenerationStore)
396410
;(context.renderOpts as any).fetchTags =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createAsyncLocalStorage } from '../../client/components/async-local-storage'
2+
3+
export function getBuiltinRequestContext():
4+
| BuiltinRequestContextValue
5+
| undefined {
6+
const _globalThis = globalThis as GlobalThisWithRequestContext
7+
const ctx = _globalThis[NEXT_REQUEST_CONTEXT_SYMBOL]
8+
return ctx?.get()
9+
}
10+
11+
const NEXT_REQUEST_CONTEXT_SYMBOL = Symbol.for('@next/request-context')
12+
13+
type GlobalThisWithRequestContext = typeof globalThis & {
14+
[NEXT_REQUEST_CONTEXT_SYMBOL]?: BuiltinRequestContext
15+
}
16+
17+
/** A request context provided by the platform. */
18+
export type BuiltinRequestContext = {
19+
get(): BuiltinRequestContextValue | undefined
20+
}
21+
22+
export type RunnableBuiltinRequestContext = BuiltinRequestContext & {
23+
run<T>(value: BuiltinRequestContextValue, callback: () => T): T
24+
}
25+
26+
export type BuiltinRequestContextValue = {
27+
waitUntil?: WaitUntil
28+
}
29+
export type WaitUntil = (promise: Promise<any>) => void
30+
31+
/** "@next/request-context" has a different signature from AsyncLocalStorage,
32+
* matching [AsyncContext.Variable](https://github.com/tc39/proposal-async-context).
33+
* We don't need a full AsyncContext adapter here, just having `.get()` is enough
34+
*/
35+
export function createLocalRequestContext(): RunnableBuiltinRequestContext {
36+
const storage = createAsyncLocalStorage<BuiltinRequestContextValue>()
37+
return {
38+
get: () => storage.getStore(),
39+
run: (value, callback) => storage.run(value, callback),
40+
}
41+
}

test/e2e/app-dir/app-fetch-deduping/app-fetch-deduping.test.ts

+5-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { findPort, waitFor } from 'next-test-utils'
1+
import { findPort, retry } from 'next-test-utils'
22
import http from 'http'
33
import { outdent } from 'outdent'
44
import { isNextDev, isNextStart, nextTestSetup } from 'e2e-utils'
@@ -104,12 +104,10 @@ describe('app-fetch-deduping', () => {
104104

105105
expect(invocation(next.cliOutput)).toBe(1)
106106

107-
// wait for the revalidation to finish
108-
await waitFor(revalidate * 1000 + 1000)
109-
110-
await next.render('/test')
111-
112-
expect(invocation(next.cliOutput)).toBe(2)
107+
await retry(async () => {
108+
await next.render('/test')
109+
expect(invocation(next.cliOutput)).toBe(2)
110+
}, 10_000)
113111
})
114112
})
115113
} else {

0 commit comments

Comments
 (0)