Repository: vercel/next.js
Pull request: [dynamicIO] use RSC dynamicness to control partial vs complete PPR result

Historically anything dynamic in a prerender would result in a partially static shell that resumes at runtime. With this change, now partial shells are only possible when RSC is dynamic. If your client prerender has any IO but your RSC tree was entirely prerenderable then Next.js will produce a complete static result and not perform a resume at runtime. This simplifies how you reason about what will and wil not make a static page. It also means that anything dynamic in the client layer will only render in browser.

Changed files:
filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -3264,7 +3264,6 @@ async function prerenderToStream(
         captureOwnerStack: undefined, // Not available in production.
       }
 
-      let clientIsDynamic = false
       let dynamicValidation = createDynamicValidationState()
 
       const prerender = (
@@ -3291,8 +3290,6 @@ async function prerenderToStream(
                     isPrerenderInterruptedError(err) ||
                     finalClientController.signal.aborted
                   ) {
-                    clientIsDynamic = true
-
                     const componentStack: string | undefined = (
                       errorInfo as any
                     ).componentStack
@@ -3356,7 +3353,12 @@ async function prerenderToStream(
         fallbackRouteParams
       )
 
-      if (serverIsDynamic || clientIsDynamic) {
+      if (serverIsDynamic) {
+        // Dynamic case
+        // We will always need to perform a "resume" render of some kind when this route is accessed
+        // because the RSC data itself is dynamic. We determine if there are any HTML holes or not
+        // but generally this is a "partial" prerender in that there will be a per-request compute
+        // concatenated to the static shell.
         if (postponed != null) {
           // Dynamic HTML case
           metadata.postponed = await getDynamicHTMLPostponedState(
@@ -3390,6 +3392,8 @@ async function prerenderToStream(
         }
       } else {
         // Static case
+        // We will not perform resumption per request. The result can be served statically to the requestor
+        // and if there was anything dynamic it will only be rendered in the browser.
         if (workStore.forceDynamic) {
           throw new StaticGenBailoutError(
             'Invariant: a Page with `dynamic = "force-dynamic"` did not trigger the dynamic pathway. This is a bug in Next.js'

filename: test/e2e/app-dir/dynamic-io/app/cases/static-rsc-dynamic-client/client.tsx
patch: @@ -0,0 +1,12 @@
+'use client'
+
+export function Time() {
+  return (
+    <p>
+      The time is{' '}
+      <span id="time" suppressHydrationWarning>
+        {new Date().toISOString()}
+      </span>
+    </p>
+  )
+}

filename: test/e2e/app-dir/dynamic-io/app/cases/static-rsc-dynamic-client/page.tsx
patch: @@ -0,0 +1,20 @@
+import { Suspense } from 'react'
+
+import { getSentinelValue } from '../../getSentinelValue'
+
+import { Time } from './client'
+
+export default function Page() {
+  return (
+    <>
+      <p>
+        This page is static in RSC but dynamic in the client. It should serve a
+        static fallback that updates in the browser.
+      </p>
+      <Suspense fallback={<span>Loading...</span>}>
+        <Time />
+      </Suspense>
+      <div id="page">{getSentinelValue()}</div>
+    </>
+  )
+}

filename: test/e2e/app-dir/dynamic-io/dynamic-io.test.ts
patch: @@ -1,5 +1,6 @@
 /* eslint-disable jest/no-standalone-expect */
 import { nextTestSetup } from 'e2e-utils'
+import cheerio from 'cheerio'
 
 describe('dynamic-io', () => {
   const { next, isNextDev, skipped } = nextTestSetup({
@@ -330,4 +331,46 @@ describe('dynamic-io', () => {
       expect($('#page-children').text()).toBe('at buildtime')
     }
   })
+
+  it('should not resume when client components are dynamic but the RSC render was static', async () => {
+    let html = await next.render('/cases/static-rsc-dynamic-client', {})
+    const $ = cheerio.load(html)
+
+    // Confirm the HTML document was sent completely
+    expect(html).toContain('</body></html>')
+
+    if (isNextDev) {
+      expect($('#layout').text()).toBe('at runtime')
+      expect($('#page').text()).toBe('at runtime')
+      // In dev we SSR the time
+      expect($('#time').length).toBe(1)
+    } else {
+      expect($('#layout').text()).toBe('at buildtime')
+      expect($('#page').text()).toBe('at buildtime')
+      // Confirm the time span is not part of the completed HTML document
+      expect($('#time').length).toBe(0)
+    }
+
+    const browser = await next.browser('/cases/static-rsc-dynamic-client')
+
+    const now = new Date()
+
+    if (isNextDev) {
+      expect(await browser.elementById('layout').text()).toBe('at runtime')
+      expect(await browser.elementById('page').text()).toBe('at runtime')
+      // Assert that we rendered a time within the last couple seconds.
+      const inPageDate = new Date(
+        await browser.waitForElementByCss('#time').text()
+      )
+      expect(inPageDate.getTime() - now.getTime()).toBeLessThan(2000)
+    } else {
+      expect(await browser.elementById('layout').text()).toBe('at buildtime')
+      expect(await browser.elementById('page').text()).toBe('at buildtime')
+      // Assert that we rendered a time within the last 2 seconds.
+      const inPageDate = new Date(
+        await browser.waitForElementByCss('#time').text()
+      )
+      expect(inPageDate.getTime() - now.getTime()).toBeLessThan(2000)
+    }
+  })
 })