Repository: vercel/next.js
Pull request: Enable `ppr` when `dynamicIO` is enabled

With this PR, we're automatically enabling `ppr` when `dynamicIO` is enabled, and forbid using `ppr: 'incremental'` or `ppr: false` together with `dynamicIO: true`.

While implementing the config validation, I noticed that the `userConfig`, `config`, and `result` objects in `assignDefaults` were untyped. This is fixed now.

Changed files:
filename: .changeset/dry-roses-nail.md
patch: @@ -0,0 +1,5 @@
+---
+"next": patch
+---
+
+Enable `ppr` when `dynamicIO` is enabled

filename: packages/next/errors.json
patch: @@ -679,5 +679,6 @@
   "678": "CacheSignal got more endRead() calls than beginRead() calls",
   "679": "A CacheSignal cannot subscribe to itself",
   "680": "CacheSignal cannot be used in the edge runtime, because `dynamicIO` does not support it.",
-  "681": "Dynamic imports should not be instrumented in the edge runtime, because `dynamicIO` doesn't support it"
+  "681": "Dynamic imports should not be instrumented in the edge runtime, because `dynamicIO` doesn't support it",
+  "682": "\\`experimental.ppr\\` can not be \\`%s\\` when \\`experimental.dynamicIO\\` is \\`true\\`. PPR is implicitly enabled when Dynamic IO is enabled."
 }

filename: packages/next/src/server/config-shared.ts
patch: @@ -637,8 +637,10 @@ export interface ExperimentalConfig {
   serverComponentsHmrCache?: boolean
 
   /**
-   * When enabled will cause IO in App Router to be excluded from prerenders
-   * unless explicitly cached.
+   * When enabled, will cause IO in App Router to be excluded from prerenders,
+   * unless explicitly cached. This also enables the experimental Partial
+   * Prerendering feature of Next.js, and it enables `react@experimental` being
+   * used for the `app` directory.
    */
   dynamicIO?: boolean
 
@@ -1198,7 +1200,7 @@ export interface NextConfig extends Record<string, any> {
   htmlLimitedBots?: RegExp
 }
 
-export const defaultConfig: NextConfig = {
+export const defaultConfig = {
   env: {},
   webpack: null,
   eslint: {
@@ -1391,7 +1393,7 @@ export const defaultConfig: NextConfig = {
   },
   htmlLimitedBots: undefined,
   bundlePagesRouterDependencies: false,
-}
+} satisfies NextConfig
 
 export async function normalizeConfig(phase: string, config: any) {
   if (typeof config === 'function') {

filename: packages/next/src/server/config.test.ts
patch: @@ -136,4 +136,44 @@ describe('loadConfig', () => {
       )
     })
   })
+
+  describe('with a canary version', () => {
+    beforeAll(() => {
+      process.env.__NEXT_VERSION = '15.4.0-canary.35'
+    })
+
+    afterAll(() => {
+      delete process.env.__NEXT_VERSION
+    })
+
+    it('errors when dynamicIO is enabled but PPR is disabled', async () => {
+      await expect(
+        loadConfig('', __dirname, {
+          customConfig: {
+            experimental: {
+              dynamicIO: true,
+              ppr: false,
+            },
+          },
+        })
+      ).rejects.toThrow(
+        '`experimental.ppr` can not be `false` when `experimental.dynamicIO` is `true`. PPR is implicitly enabled when Dynamic IO is enabled.'
+      )
+    })
+
+    it('errors when dynamicIO is enabled but PPR set to "incremental"', async () => {
+      await expect(
+        loadConfig('', __dirname, {
+          customConfig: {
+            experimental: {
+              dynamicIO: true,
+              ppr: 'incremental',
+            },
+          },
+        })
+      ).rejects.toThrow(
+        '`experimental.ppr` can not be `"incremental"` when `experimental.dynamicIO` is `true`. PPR is implicitly enabled when Dynamic IO is enabled.'
+      )
+    })
+  })
 })

filename: packages/next/src/server/config.ts
patch: @@ -143,9 +143,9 @@ function warnCustomizedOption(
 
 function assignDefaults(
   dir: string,
-  userConfig: { [key: string]: any },
+  userConfig: NextConfig & { configFileName: string },
   silent: boolean
-) {
+): NextConfigComplete {
   const configFileName = userConfig.configFileName
   if (typeof userConfig.exportTrailingSlash !== 'undefined') {
     if (!silent) {
@@ -213,9 +213,15 @@ function assignDefaults(
         })
       }
 
-      if (!!value && value.constructor === Object) {
+      const defaultValue = (defaultConfig as Record<string, unknown>)[key]
+
+      if (
+        !!value &&
+        value.constructor === Object &&
+        typeof defaultValue === 'object'
+      ) {
         currentConfig[key] = {
-          ...defaultConfig[key],
+          ...defaultValue,
           ...Object.keys(value).reduce<any>((c, k) => {
             const v = value[k]
             if (v !== undefined && v !== null) {
@@ -231,7 +237,7 @@ function assignDefaults(
       return currentConfig
     },
     {}
-  )
+  ) as NextConfig & { configFileName: string }
 
   // TODO: remove these once we've made PPR default
   // If this was defaulted to true, it implies that the configuration was
@@ -532,7 +538,7 @@ function assignDefaults(
   if (
     hasWarnedBuildActivityPosition &&
     result.devIndicators !== false &&
-    result.devIndicators?.buildActivityPosition &&
+    'buildActivityPosition' in result.devIndicators &&
     result.devIndicators.buildActivityPosition !== result.devIndicators.position
   ) {
     Log.warnOnce(
@@ -963,7 +969,11 @@ function assignDefaults(
           reason: 'key must only use characters a-z and -',
         })
       } else {
-        const handlerPath = result.experimental.cacheHandlers[key]
+        const handlerPath = (
+          result.experimental.cacheHandlers as {
+            [handlerName: string]: string | undefined
+          }
+        )[key]
 
         if (handlerPath && !existsSync(handlerPath)) {
           invalidHandlerItems.push({
@@ -1095,7 +1105,21 @@ function assignDefaults(
     result.experimental.useCache = result.experimental.dynamicIO
   }
 
-  return result
+  // If dynamicIO is enabled, we also enable PPR.
+  if (result.experimental.dynamicIO) {
+    if (
+      userConfig.experimental?.ppr === false ||
+      userConfig.experimental?.ppr === 'incremental'
+    ) {
+      throw new Error(
+        `\`experimental.ppr\` can not be \`${JSON.stringify(userConfig.experimental?.ppr)}\` when \`experimental.dynamicIO\` is \`true\`. PPR is implicitly enabled when Dynamic IO is enabled.`
+      )
+    }
+
+    result.experimental.ppr = true
+  }
+
+  return result as NextConfigComplete
 }
 
 async function applyModifyConfig(
@@ -1401,10 +1425,9 @@ export default async function loadConfig(
   // reactRoot can be updated correctly even with no next.config.js
   const completeConfig = assignDefaults(
     dir,
-    defaultConfig,
+    { ...defaultConfig, configFileName },
     silent
   ) as NextConfigComplete
-  completeConfig.configFileName = configFileName
   setHttpClientAndAgentOptions(completeConfig)
   return await applyModifyConfig(completeConfig, phase, silent)
 }
@@ -1438,7 +1461,7 @@ export function getConfiguredExperimentalFeatures(
 
       if (
         name in defaultConfig.experimental &&
-        value !== defaultConfig.experimental[name]
+        value !== (defaultConfig.experimental as Record<string, unknown>)[name]
       ) {
         configuredExperimentalFeatures.push(
           typeof value === 'boolean'