mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
### 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
364 lines
12 KiB
TypeScript
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
|
|
}
|
|
})
|
|
}
|
|
)
|
|
})
|
|
}
|
|
)
|
|
}
|
|
)
|