Turbopack: include pages/_app next/dynamic imports in each page's loadable manifest by andrewimm · Pull Request #98292 · vercel/next.js · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 93 additions & 35 deletions crates/next-api/src/pages.rs
3 changes: 3 additions & 0 deletions test/e2e/next-dynamic-in-custom-app/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function AppHeader() {
return <header id="app-header">app-level dynamic header</header>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function PageWidget() {
return <div id="page-widget">page-level dynamic widget</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'

// Regression test for https://github.com/vercel/next.js/issues/98287
//
// `next/dynamic({ ssr: true })` declared in `pages/_app` is server rendered,
// but under Turbopack its module id was missing from
// `__NEXT_DATA__.dynamicIds`, because Turbopack emits a per-page
// react-loadable manifest and `pages/_app` is a separate entrypoint. Without
// the id the client renders the loadable's fallback while hydrating, React
// reports a mismatch and throws the server rendered subtree away.
describe('next-dynamic-in-custom-app', () => {
const { next } = nextTestSetup({
files: __dirname,
})

async function getDynamicIds(path: string) {
const $ = await next.render$(path)
const nextData = JSON.parse($('#__NEXT_DATA__').html())
return { $, dynamicIds: nextData.dynamicIds ?? [] }
}

describe.each(['/', '/ssr'])('%s', (path) => {
it('reports both the _app and the page dynamic in __NEXT_DATA__.dynamicIds', async () => {
const { dynamicIds } = await getDynamicIds(path)

// One id for the `_app` declared loadable, one for the page declared one.
expect(dynamicIds).toHaveLength(2)

// Bundlers that use readable module ids (all but a Turbopack production
// build) let us pin down which loadable each id belongs to.
const readableIds = dynamicIds.filter(
(id: string | number) => typeof id === 'string'
)
if (readableIds.length > 0) {
expect(readableIds).toEqual(
expect.arrayContaining([expect.stringMatching(/app-header/)])
)
expect(readableIds).toEqual(
expect.arrayContaining([expect.stringMatching(/page-widget/)])
)
}
})

it('server renders both dynamic components instead of their fallbacks', async () => {
const { $ } = await getDynamicIds(path)

expect($('#app-header').text()).toBe('app-level dynamic header')
expect($('#page-widget').text()).toBe('page-level dynamic widget')
expect($('#app-header-loading')).toHaveLength(0)
expect($('#page-widget-loading')).toHaveLength(0)
})

it('keeps the server rendered _app chrome after hydration', async () => {
const browser = await next.browser(path)

await retry(async () => {
expect(await browser.elementByCss('#app-header').text()).toBe(
'app-level dynamic header'
)
expect(await browser.eval('window.next.router.isReady')).toBe(true)
})

const hydrationErrors = (await browser.log()).filter(
({ source, message }) =>
source === 'error' &&
/hydrat|Minified React error #(418|423|425)/i.test(message)
)
expect(hydrationErrors).toEqual([])
})
})
})
20 changes: 20 additions & 0 deletions test/e2e/next-dynamic-in-custom-app/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { AppProps } from 'next/app'
import dynamic from 'next/dynamic'

// Declaring a `ssr: true` dynamic import in `_app` is the common pattern for
// persistent site chrome. Its module id has to end up in
// `__NEXT_DATA__.dynamicIds` so the client waits for the chunk before
// hydrating, otherwise React throws the server rendered markup away.
const AppHeader = dynamic(() => import('../components/app-header'), {
ssr: true,
loading: () => <p id="app-header-loading">loading header</p>,
})

export default function App({ Component, pageProps }: AppProps) {
return (
<>
<AppHeader />
<Component {...pageProps} />
</>
)
}
15 changes: 15 additions & 0 deletions test/e2e/next-dynamic-in-custom-app/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import dynamic from 'next/dynamic'

const PageWidget = dynamic(() => import('../components/page-widget'), {
ssr: true,
loading: () => <p id="page-widget-loading">loading widget</p>,
})

export default function Page() {
return (
<main>
<p>hello world</p>
<PageWidget />
</main>
)
}
21 changes: 21 additions & 0 deletions test/e2e/next-dynamic-in-custom-app/pages/ssr.tsx
Loading