Skip to content

Commit

Permalink
experimental: unstable_after (#65038)
Browse files Browse the repository at this point in the history
Implements `unstable_after`, which lets the user schedule work to be
executed after the response is finished.

### Implementation notes

- `unstable_after()` is a dynamic function (bypassable only with `export
dynamic = "force-static"`)
- Usable in: server components (including `generateMetadata`), actions,
route handlers, middleware
- It is meant to run its callbacks even if a response didn't complete
successfully (thrown error) or called `notFound()`/`redirect()`
- Currently gated behind a `experimental.after` feature flag, because it
touches many runtime bits (including a React monkeypatch...)
- The state for `unstable_after()` in a given request lives in
`requestAsyncStorage` (added via `RequestAsyncStorageWrapper`)

- the implementation is based around two functions that we inject via
`renderOpts`:
- `waitUntil(promise)` - keep a function invocation alive until a
promise settles. it is provided as a platform primitive in serverless
contexts, and a noop in `next start`
- for serverless (nodejs), Next.js will attempt to get `waitUntil` from
`globalThis[Symbol.for('@next/request-context')].get().waitUntil`. This
should be considered unstable for now. See
`packages/next/src/server/after/wait-until-builtin.ts` for details.
- `onClose(callback)` **[NEW]** - run something when a response is done.
basically `res.on('close', callback)`, but also implemented for Web APIs
- unfortunately, for Web, this requires some potentially expensive
tricks - see `packages/next/src/server/web/web-on-close.ts`
  • Loading branch information
lubieowoce committed May 20, 2024
1 parent 3843166 commit 79bebe7
Show file tree
Hide file tree
Showing 67 changed files with 2,153 additions and 44 deletions.
1 change: 1 addition & 0 deletions packages/next-swc/crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ pub struct ExperimentalConfig {
// ---
adjust_font_fallbacks: Option<bool>,
adjust_font_fallbacks_with_size_adjust: Option<bool>,
after: Option<bool>,
amp: Option<serde_json::Value>,
app_document_preloading: Option<bool>,
case_sensitive_routes: Option<bool>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub fn get_next_cjs_optimizer_rule(enable_mdx_rs: bool) -> ModuleRule {
"userAgent".into(),
"next/dist/server/web/spec-extension/user-agent".into(),
),
("unstable_after".into(), "next/dist/server/after".into()),
]),
},
)]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,9 @@ struct ReactServerComponentValidator {
filepath: String,
app_dir: Option<PathBuf>,
invalid_server_imports: Vec<JsWord>,
invalid_client_imports: Vec<JsWord>,
invalid_server_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>,
invalid_client_imports: Vec<JsWord>,
invalid_client_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>,
pub directive_import_collection: Option<(bool, bool, Vec<ModuleImports>, Vec<String>)>,
}

Expand Down Expand Up @@ -540,7 +541,10 @@ impl ReactServerComponentValidator {
JsWord::from("react-dom/server"),
JsWord::from("next/router"),
],

invalid_client_imports: vec![JsWord::from("server-only"), JsWord::from("next/headers")],

invalid_client_lib_apis_mapping: [("next/server", vec!["unstable_after"])].into(),
}
}

Expand Down Expand Up @@ -627,14 +631,31 @@ impl ReactServerComponentValidator {
return;
}
for import in imports {
let source = import.source.0.clone();
if self.invalid_client_imports.contains(&source) {
let source = &import.source.0;

if self.invalid_client_imports.contains(source) {
report_error(
&self.app_dir,
&self.filepath,
RSCErrorKind::NextRscErrClientImport((source.to_string(), import.source.1)),
);
}

let invalid_apis = self.invalid_client_lib_apis_mapping.get(source.as_str());
if let Some(invalid_apis) = invalid_apis {
for specifier in &import.specifiers {
if invalid_apis.contains(&specifier.0.as_str()) {
report_error(
&self.app_dir,
&self.filepath,
RSCErrorKind::NextRscErrClientImport((
specifier.0.to_string(),
specifier.1,
)),
);
}
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/next/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { userAgent } from 'next/dist/server/web/spec-extension/user-agent'
export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
export { unstable_after } from 'next/dist/server/after'
2 changes: 2 additions & 0 deletions packages/next/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const serverExports = {
.userAgent,
URLPattern: require('next/dist/server/web/spec-extension/url-pattern')
.URLPattern,
unstable_after: require('next/dist/server/after').unstable_after,
}

// https://nodejs.org/api/esm.html#commonjs-namespaces
Expand All @@ -24,3 +25,4 @@ exports.ImageResponse = serverExports.ImageResponse
exports.userAgentFromString = serverExports.userAgentFromString
exports.userAgent = serverExports.userAgent
exports.URLPattern = serverExports.URLPattern
exports.unstable_after = serverExports.unstable_after
3 changes: 3 additions & 0 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,9 @@ export async function buildAppStaticPaths({
incrementalCache,
supportsDynamicHTML: true,
isRevalidate: false,
experimental: {
after: false,
},
},
},
async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,19 @@ export function getRender({
event?: NextFetchEvent
) {
const extendedReq = new WebNextRequest(request)
const extendedRes = new WebNextResponse()
const extendedRes = new WebNextResponse(
undefined,
// tracking onClose adds overhead, so only do it if `experimental.after` is on.
!!process.env.__NEXT_AFTER
)

handler(extendedReq, extendedRes)
const result = await extendedRes.toResponse()

if (event?.waitUntil) {
// TODO(after):
// remove `internal_runWithWaitUntil` and the `internal-edge-wait-until` module
// when consumers switch to `unstable_after`.
const waitUntilPromise = internal_getCurrentFunctionWaitUntil()
if (waitUntilPromise) {
event.waitUntil(waitUntilPromise)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function getDefineEnv({
: '',
'process.env.NEXT_MINIMAL': '',
'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr),
'process.env.__NEXT_AFTER': config.experimental.after ?? false,
'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,
'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': fetchCacheKeyPrefix ?? '',
'process.env.__NEXT_MIDDLEWARE_MATCHERS': middlewareMatchers ?? [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/ada
// Share the instance module in the next-shared layer
import { requestAsyncStorage } from './request-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
import type { AfterContext } from '../../server/after/after-context'

export interface RequestStore {
readonly headers: ReadonlyHeaders
Expand All @@ -17,6 +18,7 @@ export interface RequestStore {
Record<string, { files: string[] }>
>
readonly assetPrefix: string
readonly afterContext: AfterContext | undefined
}

export type RequestAsyncStorage = AsyncLocalStorage<RequestStore>
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export async function exportAppImpl(
isAppPPREnabled: checkIsAppPPREnabled(nextConfig.experimental.ppr),
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
swrDelta: nextConfig.experimental.swrDelta,
after: nextConfig.experimental.after ?? false,
},
}

Expand Down
7 changes: 6 additions & 1 deletion packages/next/src/export/routes/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
import { SERVER_DIRECTORY } from '../../shared/lib/constants'
import { hasNextSupport } from '../../telemetry/ci-info'
import type { ExperimentalConfig } from '../../server/config-shared'

export const enum ExportedAppRouteFiles {
BODY = 'BODY',
Expand All @@ -37,7 +38,8 @@ export async function exportAppRoute(
incrementalCache: IncrementalCache | undefined,
distDir: string,
htmlFilepath: string,
fileWriter: FileWriter
fileWriter: FileWriter,
experimental: Required<Pick<ExperimentalConfig, 'after'>>
): Promise<ExportRouteResult> {
// Ensure that the URL is absolute.
req.url = `http://localhost:3000${req.url}`
Expand All @@ -64,10 +66,13 @@ export async function exportAppRoute(
notFoundRoutes: [],
},
renderOpts: {
experimental: experimental,
originalPathname: page,
nextExport: true,
supportsDynamicHTML: false,
incrementalCache,
waitUntil: undefined,
onClose: undefined,
},
}

Expand Down
6 changes: 5 additions & 1 deletion packages/next/src/export/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import type { FontConfig } from '../server/font-utils'
import type { ExportPathMap, NextConfigComplete } from '../server/config-shared'
import type { Span } from '../trace'
import type { Revalidate } from '../server/lib/revalidate'
import type { NextEnabledDirectories } from '../server/base-server'
import type {
NextEnabledDirectories,
RequestLifecycleOpts,
} from '../server/base-server'
import type {
SerializableTurborepoAccessTraceResult,
TurborepoAccessTraceResult,
Expand Down Expand Up @@ -97,6 +100,7 @@ export type WorkerRenderOptsPartial = PagesRenderOptsPartial &
AppRenderOptsPartial

export type WorkerRenderOpts = WorkerRenderOptsPartial &
RequestLifecycleOpts &
LoadComponentsReturnType

export type ExportWorker = (
Expand Down
5 changes: 4 additions & 1 deletion packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ async function exportPageImpl(
incrementalCache,
distDir,
htmlFilepath,
fileWriter
fileWriter,
input.renderOpts.experimental
)
}

Expand All @@ -274,6 +275,8 @@ async function exportPageImpl(
...input.renderOpts.experimental,
isRoutePPREnabled,
},
waitUntil: undefined,
onClose: undefined,
}

if (hasNextSupport) {
Expand Down
Loading

0 comments on commit 79bebe7

Please sign in to comment.