Files
Jiachi LiuandGitHub 142050ddc0 Fix css FOUC in dynamic component (#64294)
### What

CSS imports in components that loaded by `next/dynamic` in client
components will cause the css are missing initial in
SSR, and loaded later on client side which will lead to FOUC. This PR
fixes the issue and get CSS preloaded in the SSR for dynamic components.

### Why

The CSS from client components that created through `next/dynamic` are
not collected in the SSR, unlike RSC rendering we already collect the
CSS resources for each entry so we included them in the server rendering
so the styles are availble at that time. But for client components, we
didn't traverse all the client components and collect the CSS resources.

In pages router we kinda collect all the dynamic imports and preload
them during SSR, but this approach is not able to be applied to app
router due to different architecture. Since we already have all the
dynamic imports info and their related chunks in
react-loadable-manifest, so we can do the similar "preloading" thing in
app router. We use the current dynamic module key (`app/page.js ->
../components/foo.js`) which created by SWC transform and match it in
the react loadable manifest that accessed from `AsyncLocalStorage`, to
get the css files created by webpack then render them as preload
styleshee links. In this way we can SSR all the related CSS resources
for dynamic client components.

The reason we pass down the react loadable manifest through
`AsyncLocalStorage` is that it's sort of exclude the manifest from RSC
payload as it's not required for hydration, but only required for SSR.

Note: this issue only occurred in dynamic rendering case for client
components.

### Other Changes Overview

- Change the react loadable manifest key from pages dir based relative
path to a source dir based relative path, to support cases with both
directory or only one of them

Closes NEXT-2578
Fixes #61212
Fixes #61111
Fixes #62940

Replacement for #64021 but only with production test
2024-04-10 16:16:20 +02:00

364 lines
12 KiB
TypeScript

import { join } from 'path'
import cheerio from 'cheerio'
import webdriver from 'next-webdriver'
import { createNext, FileRef } from 'e2e-utils'
import { renderViaHTTP, check, hasRedbox } from 'next-test-utils'
import { NextInstance } from 'e2e-utils'
const customDocumentGipFiles = {
'pages/_document.js': `
import { Html, Main, NextScript, Head } from 'next/document'
export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
Document.getInitialProps = (ctx) => {
return ctx.defaultGetInitialProps(ctx)
}
`,
}
describe.each([
['', 'swc'],
['/docs', 'swc'],
['', 'document.getInitialProps'],
['', 'babel'],
])(
'basic next/dynamic usage, basePath: %p with %p compiler',
(
basePath: string,
testCase: 'swc' | 'babel' | 'document.getInitialProps'
) => {
;(process.env.TURBOPACK && testCase === 'babel' ? describe.skip : describe)(
testCase,
() => {
let next: NextInstance
beforeAll(async () => {
next = await createNext({
files: {
components: new FileRef(
join(__dirname, 'next-dynamic/components')
),
pages: new FileRef(join(__dirname, 'next-dynamic/pages')),
...(testCase === 'document.getInitialProps' &&
customDocumentGipFiles),
...(testCase === 'babel' && {
'.babelrc': `{ "presets": ["next/babel"] }`,
}),
},
nextConfig: {
basePath,
},
})
})
afterAll(() => next.destroy())
async function get$(path, query?: any) {
const html = await renderViaHTTP(next.url, path, query)
return cheerio.load(html)
}
describe('Dynamic import', () => {
describe('default behavior', () => {
it('should render dynamic import components', async () => {
const $ = await get$(basePath + '/dynamic/ssr')
// Make sure the client side knows it has to wait for the bundle
expect(
JSON.parse($('#__NEXT_DATA__').html()).dynamicIds
).toContain('pages/dynamic/ssr.js -> ../../components/hello1')
expect($('body').text()).toMatch(/Hello World 1/)
})
it('should render dynamic import components using a function as first parameter', async () => {
const $ = await get$(basePath + '/dynamic/function')
// Make sure the client side knows it has to wait for the bundle
expect(
JSON.parse($('#__NEXT_DATA__').html()).dynamicIds
).toContain(
'pages/dynamic/function.js -> ../../components/hello1'
)
expect($('body').text()).toMatch(/Hello World 1/)
})
it('should render even there are no physical chunk exists', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/no-chunk'
)
await check(
() => browser.elementByCss('body').text(),
/Welcome, normal/
)
await check(
() => browser.elementByCss('body').text(),
/Welcome, dynamic/
)
} finally {
if (browser) {
await browser.close()
}
}
})
it('should SSR nested dynamic components and skip nonSSR ones', async () => {
const $ = await get$(basePath + '/dynamic/nested')
const text = $('#__next').text()
expect(text).toContain('Nested 1')
expect(text).toContain('Nested 2')
expect(text).not.toContain('Browser hydrated')
})
it('should hydrate nested chunks', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/nested'
)
await check(
() => browser.elementByCss('body').text(),
/Nested 1/
)
await check(
() => browser.elementByCss('body').text(),
/Nested 2/
)
await check(
() => browser.elementByCss('body').text(),
/Browser hydrated/
)
if ((global as any).browserName === 'chrome') {
const logs = await browser.log('browser')
logs.forEach((logItem) => {
expect(logItem.message).not.toMatch(
/Expected server HTML to contain/
)
})
}
} finally {
if (browser) {
await browser.close()
}
}
})
it('should render the component Head content', async () => {
let browser
try {
browser = await webdriver(next.url, basePath + '/dynamic/head')
await check(() => browser.elementByCss('body').text(), /test/)
const backgroundColor = await browser
.elementByCss('.dynamic-style')
.getComputedCss('background-color')
const height = await browser
.elementByCss('.dynamic-style')
.getComputedCss('height')
expect(height).toBe('200px')
expect(backgroundColor).toMatch(/rgba?\(0, 128, 0/)
} finally {
if (browser) {
await browser.close()
}
}
})
})
describe('ssr:false option', () => {
it('should not render loading on the server side', async () => {
const $ = await get$(basePath + '/dynamic/no-ssr')
expect($('body').html()).not.toContain('"dynamicIds"')
expect($('body').text()).not.toMatch('loading...')
})
it('should render the component on client side', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/no-ssr'
)
await check(
() => browser.elementByCss('body').text(),
/navigator/
)
expect(await hasRedbox(browser)).toBe(false)
} finally {
if (browser) {
await browser.close()
}
}
})
it('should import and render the ESM module correctly on client side', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/no-ssr-esm'
)
await check(
() => browser.elementByCss('body').text(),
/esm.mjs/
)
expect(await hasRedbox(browser)).toBe(false)
} finally {
if (browser) {
await browser.close()
}
}
})
})
describe('ssr:true option', () => {
it('Should render the component on the server side', async () => {
const $ = await get$(basePath + '/dynamic/ssr-true')
expect($('body').html()).toContain('"dynamicIds"')
expect($('p').text()).toBe('Hello World 1')
})
it('should render the component on client side', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/ssr-true'
)
await check(
() => browser.elementByCss('body').text(),
/Hello World 1/
)
} finally {
if (browser) {
await browser.close()
}
}
})
if (!(global as any).isNextDev) {
it('should not include ssr:false imports to server trace', async () => {
const trace = JSON.parse(
await next.readFile(
'.next/server/pages/dynamic/no-ssr.js.nft.json'
)
) as { files: string[] }
expect(trace).not.toContain('navigator')
})
}
})
// Turbopack doesn't have this feature.
;(process.env.TURBOPACK ? describe.skip : describe)(
'custom chunkfilename',
() => {
it('should render the correct filename', async () => {
const $ = await get$(basePath + '/dynamic/chunkfilename')
expect($('body').text()).toMatch(/test chunkfilename/)
expect($('html').html()).toMatch(/hello-world\.js/)
})
it('should render the component on client side', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/chunkfilename'
)
await check(
() => browser.elementByCss('body').text(),
/test chunkfilename/
)
} finally {
if (browser) {
await browser.close()
}
}
})
}
)
describe('custom loading', () => {
it('should render custom loading on the server side when `ssr:false` and `loading` is provided', async () => {
const $ = await get$(basePath + '/dynamic/no-ssr-custom-loading')
expect($('p').text()).toBe('LOADING')
})
it('should render the component on client side', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/no-ssr-custom-loading'
)
await check(
() => browser.elementByCss('body').text(),
/Hello World 1/
)
} finally {
if (browser) {
await browser.close()
}
}
})
})
// TODO: Make this test work with Turbopack. Currently the test relies on `chunkFileName` which is not supported by Turbopack.
;(process.env.TURBOPACK ? describe.skip : describe)(
'Multiple modules',
() => {
it('should only include the rendered module script tag', async () => {
const $ = await get$(basePath + '/dynamic/multiple-modules')
const html = $('html').html()
expect(html).toMatch(/hello1\.js/)
expect(html).not.toMatch(/hello2\.js/)
})
it('should only load the rendered module in the browser', async () => {
let browser
try {
browser = await webdriver(
next.url,
basePath + '/dynamic/multiple-modules'
)
const html = await browser.eval(
'document.documentElement.innerHTML'
)
expect(html).toMatch(/hello1\.js/)
expect(html).not.toMatch(/hello2\.js/)
} finally {
if (browser) {
await browser.close()
}
}
})
it('should only render one bundle if component is used multiple times', async () => {
const $ = await get$(basePath + '/dynamic/multiple-modules')
const html = $('html').html()
try {
expect(html.match(/chunks[\\/]hello1\.js/g).length).toBe(1)
expect(html).not.toMatch(/hello2\.js/)
} catch (err) {
console.error(html)
throw err
}
})
}
)
})
}
)
}
)