Skip to content

Commit

Permalink
Introduce dynamicIO experiment (vercel#67571)
Browse files Browse the repository at this point in the history
`nextConfig.experimental.dynamicIO: boolean` is a new experimental flag
that changes the static generation bailout heuristic and PPR postpone
heuristic to exclude async work that is not resolvable in the current
Task's microtask queue from prerenders. functionally it means that in
this mode fetches and db queries will now not be prerenderable unless
you opt into it with a caching API like `unstable_cache()` or the fetch
`cache: 'force-cache'` option.
  • Loading branch information
gnoff authored Aug 29, 2024
1 parent f8ec826 commit b305c2d
Show file tree
Hide file tree
Showing 63 changed files with 3,224 additions and 504 deletions.
9 changes: 9 additions & 0 deletions crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,8 @@ pub struct ExperimentalConfig {
/// directory.
ppr: Option<ExperimentalPartialPrerendering>,
taint: Option<bool>,
#[serde(rename = "dynamicIO")]
dynamic_io: Option<bool>,
proxy_timeout: Option<f64>,
/// enables the minification of server code.
server_minification: Option<bool>,
Expand Down Expand Up @@ -1076,6 +1078,13 @@ impl NextConfig {
Ok(Vc::cell(self.await?.experimental.taint.unwrap_or(false)))
}

#[turbo_tasks::function]
pub async fn enable_dynamic_io(self: Vc<Self>) -> Result<Vc<bool>> {
Ok(Vc::cell(
self.await?.experimental.dynamic_io.unwrap_or(false),
))
}

#[turbo_tasks::function]
pub async fn use_swc_css(self: Vc<Self>) -> Result<Vc<bool>> {
Ok(Vc::cell(
Expand Down
25 changes: 18 additions & 7 deletions crates/next-core/src/next_import_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,14 @@ pub async fn get_next_client_import_map(
match ty.into_value() {
ClientContextType::Pages { .. } => {}
ClientContextType::App { app_dir } => {
let react_flavor =
if *next_config.enable_ppr().await? || *next_config.enable_taint().await? {
"-experimental"
} else {
""
};
let react_flavor = if *next_config.enable_ppr().await?
|| *next_config.enable_taint().await?
|| *next_config.enable_dynamic_io().await?
{
"-experimental"
} else {
""
};

import_map.insert_exact_alias(
"react",
Expand Down Expand Up @@ -665,7 +667,12 @@ async fn rsc_aliases(
) -> Result<()> {
let ppr = *next_config.enable_ppr().await?;
let taint = *next_config.enable_taint().await?;
let react_channel = if ppr || taint { "-experimental" } else { "" };
let dynamic_io = *next_config.enable_dynamic_io().await?;
let react_channel = if ppr || taint || dynamic_io {
"-experimental"
} else {
""
};
let react_client_package = get_react_client_package(&next_config).await?;

let mut alias = IndexMap::new();
Expand Down Expand Up @@ -695,10 +702,12 @@ async fn rsc_aliases(
"react-server-dom-webpack/client.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/client.edge"),
"react-server-dom-webpack/server.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/server.edge"),
"react-server-dom-webpack/server.node" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/server.node"),
"react-server-dom-webpack/static.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/static.edge"),
"react-server-dom-turbopack/client" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/client"),
"react-server-dom-turbopack/client.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/client.edge"),
"react-server-dom-turbopack/server.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/server.edge"),
"react-server-dom-turbopack/server.node" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/server.node"),
"react-server-dom-turbopack/static.edge" => format!("next/dist/compiled/react-server-dom-turbopack{react_channel}/static.edge"),
});

if runtime == NextRuntime::NodeJs {
Expand Down Expand Up @@ -726,8 +735,10 @@ async fn rsc_aliases(
"react-dom" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-dom"),
"react-server-dom-webpack/server.edge" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-server-edge"),
"react-server-dom-webpack/server.node" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-server-node"),
"react-server-dom-webpack/static.edge" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-static-edge"),
"react-server-dom-turbopack/server.edge" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-server-edge"),
"react-server-dom-turbopack/server.node" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-server-node"),
"react-server-dom-turbopack/static.edge" => format!("next/dist/server/route-modules/app-page/vendored/rsc/react-server-dom-turbopack-static-edge"),
"next/navigation" => format!("next/dist/api/navigation.react-server"),

// Needed to make `react-dom/server` work.
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/build/create-compiler-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export function createRSCRendererAliases(bundledReactChannel: string) {
'react-server-dom-webpack/client.edge$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/client.edge`,
'react-server-dom-webpack/server.edge$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/server.edge`,
'react-server-dom-webpack/server.node$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/server.node`,
'react-server-dom-webpack/static.edge$': `next/dist/compiled/react-server-dom-webpack${bundledReactChannel}/static.edge`,
}
}

Expand Down Expand Up @@ -295,6 +296,7 @@ export function createRSCAliases(
'react-dom$': `next/dist/server/route-modules/app-page/vendored/${layer}/react-dom`,
'react-server-dom-webpack/server.edge$': `next/dist/server/route-modules/app-page/vendored/${layer}/react-server-dom-webpack-server-edge`,
'react-server-dom-webpack/server.node$': `next/dist/server/route-modules/app-page/vendored/${layer}/react-server-dom-webpack-server-node`,
'react-server-dom-webpack/static.edge$': `next/dist/server/route-modules/app-page/vendored/${layer}/react-server-dom-webpack-static-edge`,
})
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/next/src/build/templates/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,18 @@ const routeModule = new AppRouteRouteModule({
// Pull out the exports that we need to expose from the module. This should
// be eliminated when we've moved the other routes to the new format. These
// are used to hook into the route.
const { requestAsyncStorage, staticGenerationAsyncStorage, serverHooks } =
routeModule
const {
requestAsyncStorage,
staticGenerationAsyncStorage,
prerenderAsyncStorage,
serverHooks,
} = routeModule

function patchFetch() {
return _patchFetch({
staticGenerationAsyncStorage,
requestAsyncStorage,
prerenderAsyncStorage,
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@ export async function buildAppStaticPaths({
isRevalidate: false,
experimental: {
after: false,
dynamicIO: false,
},
},
},
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 @@ -357,6 +357,7 @@ async function exportAppImpl(
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
swrDelta: nextConfig.swrDelta,
after: nextConfig.experimental.after ?? false,
dynamicIO: nextConfig.experimental.dynamicIO ?? false,
},
reactMaxHeadersLength: nextConfig.reactMaxHeadersLength,
}
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/export/routes/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function exportAppRoute(
distDir: string,
htmlFilepath: string,
fileWriter: FileWriter,
experimental: Required<Pick<ExperimentalConfig, 'after'>>
experimental: Required<Pick<ExperimentalConfig, 'after' | 'dynamicIO'>>
): Promise<ExportRouteResult> {
// Ensure that the URL is absolute.
req.url = `http://localhost:3000${req.url}`
Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ export function createMetadataComponents({
errorType
)

// We construct this instrumented promise to allow React.use to synchronously unwrap
// it if it has already settled.
// We instrument the promise compatible with React. This isn't necessary but we can
// perform a similar trick in synchronously unwrapping in the outlet component to avoid
// ticking a new microtask unecessarily
const metadataReady: Promise<void> & { status: string; value: unknown } =
pendingMetadata.then(
([error]) => {
Expand Down
6 changes: 5 additions & 1 deletion packages/next/src/lib/needs-experimental-react.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { NextConfig } from '../server/config-shared'

export function needsExperimentalReact(config: NextConfig) {
return Boolean(config.experimental?.ppr || config.experimental?.taint)
return Boolean(
config.experimental?.ppr ||
config.experimental?.taint ||
config.experimental?.dynamicIO
)
}
17 changes: 17 additions & 0 deletions packages/next/src/lib/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ export const scheduleImmediate = <T = void>(cb: ScheduledFn<T>): void => {
export function atLeastOneTask() {
return new Promise<void>((resolve) => scheduleImmediate(resolve))
}

/**
* This utility function is extracted to make it easier to find places where we are doing
* specific timing tricks to try to schedule work after React has rendered. This is especially
* important at the moment because Next.js uses the edge builds of React which use setTimeout to
* schedule work when you might expect that something like setImmediate would do the trick.
*
* Long term we should switch to the node versions of React rendering when possible and then
* update this to use setImmediate rather than setTimeout
*/
export function waitAtLeastOneReactRenderTask(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'edge') {
return new Promise((r) => setTimeout(r, 0))
} else {
return new Promise((r) => setImmediate(r))
}
}
179 changes: 179 additions & 0 deletions packages/next/src/server/app-render/app-render-prerender-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { InvariantError } from '../../shared/lib/invariant-error'

/**
* This is a utility function to make scheduling sequential tasks that run back to back easier.
* We schedule on the same queue (setImmediate) at the same time to ensure no other events can sneak in between.
*/
export function prerenderAndAbortInSequentialTasks<R>(
prerender: () => Promise<R>,
abort: () => void
): Promise<R> {
if (process.env.NEXT_RUNTIME === 'edge') {
throw new InvariantError(
'`prerenderAndAbortInSequentialTasks` should not be called in edge runtime.'
)
} else {
return new Promise((resolve, reject) => {
let pendingResult: Promise<R>
setImmediate(() => {
try {
pendingResult = prerender()
} catch (err) {
reject(err)
}
})
setImmediate(() => {
abort()
resolve(pendingResult)
})
})
}
}

// React's RSC prerender function will emit an incomplete flight stream when using `prerender`. If the connection
// closes then whatever hanging chunks exist will be errored. This is because prerender (an experimental feature)
// has not yet implemented a concept of resume. For now we will simulate a paused connection by wrapping the stream
// in one that doesn't close even when the underlying is complete.
export class ReactServerResult {
private _stream: null | ReadableStream<Uint8Array>

constructor(stream: ReadableStream<Uint8Array>) {
this._stream = stream
}

tee() {
if (this._stream === null) {
throw new Error(
'Cannot tee a ReactServerResult that has already been consumed'
)
}
const tee = this._stream.tee()
this._stream = tee[0]
return tee[1]
}

consume() {
if (this._stream === null) {
throw new Error(
'Cannot consume a ReactServerResult that has already been consumed'
)
}
const stream = this._stream
this._stream = null
return stream
}
}

export type ReactServerPrerenderResolveToType = {
prelude: ReadableStream<Uint8Array>
}

export async function createReactServerPrerenderResult(
underlying: Promise<ReactServerPrerenderResolveToType>
): Promise<ReactServerPrerenderResult> {
const chunks: Array<Uint8Array> = []
const { prelude } = await underlying
const reader = prelude.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
} else {
chunks.push(value)
}
}
return new ReactServerPrerenderResult(chunks)
}

export async function createReactServerPrerenderResultFromRender(
underlying: ReadableStream<Uint8Array>
): Promise<ReactServerPrerenderResult> {
const chunks: Array<Uint8Array> = []
const reader = underlying.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
} else {
chunks.push(value)
}
}
return new ReactServerPrerenderResult(chunks)
}
export class ReactServerPrerenderResult {
private _chunks: null | Array<Uint8Array>

private assertChunks(expression: string): Array<Uint8Array> {
if (this._chunks === null) {
throw new InvariantError(
`Cannot \`${expression}\` on a ReactServerPrerenderResult that has already been consumed.`
)
}
return this._chunks
}

private consumeChunks(expression: string): Array<Uint8Array> {
const chunks = this.assertChunks(expression)
this.consume()
return chunks
}

consume(): void {
this._chunks = null
}

constructor(chunks: Array<Uint8Array>) {
this._chunks = chunks
}

asUnclosingStream(): ReadableStream<Uint8Array> {
const chunks = this.assertChunks('asUnclosingStream()')
return createUnclosingStream(chunks)
}

consumeAsUnclosingStream(): ReadableStream<Uint8Array> {
const chunks = this.consumeChunks('consumeAsUnclosingStream()')
return createUnclosingStream(chunks)
}

asStream(): ReadableStream<Uint8Array> {
const chunks = this.assertChunks('asStream()')
return createClosingStream(chunks)
}

consumeAsStream(): ReadableStream<Uint8Array> {
const chunks = this.consumeChunks('consumeAsStream()')
return createClosingStream(chunks)
}
}

function createUnclosingStream(
chunks: Array<Uint8Array>
): ReadableStream<Uint8Array> {
let i = 0
return new ReadableStream({
async pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++])
}
// we intentionally keep the stream open. The consumer will clear
// out chunks once finished and the remaining memory will be GC'd
// when this object goes out of scope
},
})
}

function createClosingStream(
chunks: Array<Uint8Array>
): ReadableStream<Uint8Array> {
let i = 0
return new ReadableStream({
async pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++])
} else {
controller.close()
}
},
})
}
Loading

0 comments on commit b305c2d

Please sign in to comment.