Skip to content

Commit

Permalink
Introduce dynamic IO experimental configuration
Browse files Browse the repository at this point in the history
In this mode prerenders must complete in a single render Task. When PPR is off if a prerender is incomplete after a single Render Task that page is considered dynamic. When PPR is on if a prerender is incomplete after a single Render Task then all the branches that are incomplete will postpone triggering the nearest parent suspense boundary.

The Dynamic APIs that bail out of prerendering will no longer trigger a postpone via a throw but will instead abort the render synchronously. This is very aggressive and to make this mode useful we intend to alter these dynamic APIs in a way that will allow their use to only exclude their local sub-tree.

If you experiment with this mode expect that many of your previously static pages will become dynamic and your mostly static PPR prerenders will become empty.

React was bumped and nwo there is a new prerender method in the flight package. We can use this to implement an improved technique to ensure proper timing of task boundaries. This change switches to using prerender which also means that dynamicIO by itself must opt into experimental react
  • Loading branch information
gnoff committed Aug 25, 2024
1 parent bc5bba8 commit 54fbece
Show file tree
Hide file tree
Showing 62 changed files with 3,263 additions and 496 deletions.
8 changes: 8 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,7 @@ pub struct ExperimentalConfig {
/// directory.
ppr: Option<ExperimentalPartialPrerendering>,
taint: Option<bool>,
dynamic_i_o: Option<bool>,
proxy_timeout: Option<f64>,
/// enables the minification of server code.
server_minification: Option<bool>,
Expand Down Expand Up @@ -1076,6 +1077,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_i_o.unwrap_or(false),
))
}

#[turbo_tasks::function]
pub async fn use_swc_css(self: Vc<Self>) -> Result<Vc<bool>> {
Ok(Vc::cell(
Expand Down
24 changes: 17 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,6 +735,7 @@ 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"),
"next/navigation" => format!("next/dist/api/navigation.react-server"),
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 @@ -1407,6 +1407,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 @@ -84,8 +84,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
)
}
226 changes: 226 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,226 @@
/**
* 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
* import 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
*
* A shorter term work around would be to patch React to use setImmediate instead of setTimeout
* in the edge builds since this might also avoid setTimeout throttling.
*/
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))
}
}

/**
* 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. We pass the return value from the first one to the second one to make typing
* easier.
*
* We do not call the second function if the first errored. practiaclly spea
*/
export function prerenderAndAbortInSequentialTasks<R>(
prerender: () => Promise<R>,
abort: () => void
): Promise<R> {
if (process.env.NEXT_RUNTIME === 'edge') {
throw new Error(
'schedulePrerendreWork should not be called in edge runtime. This is a bug in Next.js'
)
}
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
}
}

type ReactPromise<T> = Promise<T> & {
status?: string
value?: T
reason?: unknown
}
export type ReactServerPrerenderResolveToType = {
prelude: ReadableStream<Uint8Array>
}

export async function createReactServerPrerenderResult(
underlying: Promise<ReactServerPrerenderResolveToType>
): Promise<ReactServerPrerenderResult>
export async function createReactServerPrerenderResult(
underlying: ReactPromise<ReactServerPrerenderResolveToType>
): Promise<ReactServerPrerenderResult> {
underlying.catch(() => {})

const chunks: Array<Uint8Array> = []
const { prelude } = await underlying
await new Promise((resolve, reject) => {
const reader = prelude.getReader()
function progress(result: ReadableStreamReadResult<Uint8Array>) {
if (result.done) {
resolve(chunks)
return
} else {
chunks.push(result.value)
reader.read().then(progress, error)
}
}
function error(e: unknown) {
reader.cancel(e)
reject(e)
}
reader.read().then(progress, error)
})
return new ReactServerPrerenderResult(chunks)
}

export async function createReactServerPrerenderResultFromRender(
underlying: ReadableStream<Uint8Array>
): Promise<ReactServerPrerenderResult> {
const chunks: Array<Uint8Array> = []
await new Promise((resolve, reject) => {
const reader = underlying.getReader()
function progress(result: ReadableStreamReadResult<Uint8Array>) {
if (result.done) {
resolve(chunks)
return
} else {
chunks.push(result.value)
reader.read().then(progress, error)
}
}
function error(e: unknown) {
reader.cancel(e)
reject(e)
}
reader.read().then(progress, error)
})
return new ReactServerPrerenderResult(chunks)
}
export class ReactServerPrerenderResult {
private _chunks: null | Array<Uint8Array>

private assertChunks(expression: string): Array<Uint8Array> {
if (this._chunks === null) {
throw new Error(
`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 54fbece

Please sign in to comment.