Skip to content

Commit a16d8dd

Browse files
authoredJul 6, 2022
Filter proper chunks from chunk group for client components (#38379)
Client components might result in a page chunk or a standalone splitted chunk marked in flight manifest, this PR filters out the page chunk for standalone chunk so that while loading a client chunk (like for `next/link`) it won't load the page chunk to break the hydration. `chunk.ids` is not enough for getting required chunks, so we get the chunks from chunk group then filter the required ones. tests: Re-enable few previous rsc tests chore: refactor few webpack api usage ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md`
1 parent f2b8a6b commit a16d8dd

File tree

5 files changed

+142
-140
lines changed

5 files changed

+142
-140
lines changed
 

‎packages/next/build/webpack/plugins/flight-manifest-plugin.ts

+39-30
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FLIGHT_MANIFEST } from '../../../shared/lib/constants'
1010
import { clientComponentRegex } from '../loaders/utils'
1111
import { relative } from 'path'
1212
import { getEntrypointFiles } from './build-manifest-plugin'
13+
import type { webpack5 } from 'next/dist/compiled/webpack/webpack'
1314

1415
// This is the module that will be used to anchor all client references to.
1516
// I.e. it will have all the client files as async deps from this point on.
@@ -66,13 +67,17 @@ export class FlightManifestPlugin {
6667
})
6768
}
6869

69-
createAsset(assets: any, compilation: any, context: string) {
70+
createAsset(assets: any, compilation: webpack5.Compilation, context: string) {
7071
const manifest: any = {}
7172
const appDir = this.appDir
7273
const dev = this.dev
7374

7475
compilation.chunkGroups.forEach((chunkGroup: any) => {
75-
function recordModule(chunk: any, id: string | number, mod: any) {
76+
function recordModule(
77+
chunk: webpack5.Chunk,
78+
id: string | number,
79+
mod: any
80+
) {
7681
const resource: string = mod.resource
7782

7883
// TODO: Hook into deps instead of the target module.
@@ -120,46 +125,49 @@ export class FlightManifestPlugin {
120125

121126
const moduleExportedKeys = ['', '*']
122127
.concat(
123-
[...exportsInfo.exports].map((exportInfo) => {
124-
if (exportInfo.provided) {
125-
return exportInfo.name
126-
}
127-
return null
128-
}),
128+
[...exportsInfo.exports]
129+
.filter((exportInfo) => exportInfo.provided)
130+
.map((exportInfo) => exportInfo.name),
129131
...cjsExports
130132
)
131133
.filter((name) => name !== null)
132134

133135
// Get all CSS files imported in that chunk.
134136
const cssChunks: string[] = []
135-
for (const entrypoint of chunk._groups) {
136-
if (entrypoint.getFiles) {
137-
const files = getEntrypointFiles(entrypoint)
138-
for (const file of files) {
139-
if (file.endsWith('.css')) {
140-
cssChunks.push(file)
141-
}
137+
for (const entrypoint of chunk.groupsIterable) {
138+
const files = getEntrypointFiles(entrypoint)
139+
for (const file of files) {
140+
if (file.endsWith('.css')) {
141+
cssChunks.push(file)
142142
}
143143
}
144144
}
145145

146146
moduleExportedKeys.forEach((name) => {
147+
let requiredChunks = []
147148
if (!moduleExports[name]) {
149+
const isRelatedChunk = (c: webpack5.Chunk) =>
150+
// If current chunk is a page, it should require the related page chunk;
151+
// If current chunk is a component, it should filter out the related page chunk;
152+
chunk.name?.startsWith('pages/') || !c.name?.startsWith('pages/')
153+
154+
if (appDir) {
155+
requiredChunks = chunkGroup.chunks
156+
.filter(isRelatedChunk)
157+
.map((requiredChunk: webpack5.Chunk) => {
158+
return (
159+
requiredChunk.id +
160+
':' +
161+
(requiredChunk.name || requiredChunk.id) +
162+
(dev ? '' : '-' + requiredChunk.hash)
163+
)
164+
})
165+
}
166+
148167
moduleExports[name] = {
149168
id,
150169
name,
151-
chunks: appDir
152-
? chunk.ids
153-
.map((chunkId: string) => {
154-
return (
155-
chunkId +
156-
':' +
157-
(chunk.name || chunkId) +
158-
(dev ? '' : '-' + chunk.hash)
159-
)
160-
})
161-
.concat(cssChunks)
162-
: [],
170+
chunks: requiredChunks.concat(cssChunks),
163171
}
164172
}
165173
if (!moduleIdMapping[id][name]) {
@@ -174,7 +182,7 @@ export class FlightManifestPlugin {
174182
manifest.__ssr_module_mapping__ = moduleIdMapping
175183
}
176184

177-
chunkGroup.chunks.forEach((chunk: any) => {
185+
chunkGroup.chunks.forEach((chunk: webpack5.Chunk) => {
178186
const chunkModules =
179187
compilation.chunkGraph.getChunkModulesIterable(chunk)
180188
for (const mod of chunkModules) {
@@ -183,8 +191,9 @@ export class FlightManifestPlugin {
183191
recordModule(chunk, modId, mod)
184192

185193
// If this is a concatenation, register each child to the parent ID.
186-
if (mod.modules) {
187-
mod.modules.forEach((concatenatedMod: any) => {
194+
const anyModule = mod as any
195+
if (anyModule.modules) {
196+
anyModule.modules.forEach((concatenatedMod: any) => {
188197
recordModule(chunk, modId, concatenatedMod)
189198
})
190199
}

‎test/integration/react-server-components/basic/app/page.server.js

-7
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
import Nav from '../components/nav'
2-
import Script from 'next/script'
3-
import Head from 'next/head'
42

53
const envVar = process.env.ENV_VAR_TEST
64
const headerKey = 'x-next-test-client'
75

86
export default function Index({ header }) {
97
return (
108
<div>
11-
<Head>
12-
<meta name="rsc-title" content="index" />
13-
<title>{`hello, ${envVar}`}</title>
14-
</Head>
159
<h1>{`component:index.server`}</h1>
1610
<div>{'env:' + envVar}</div>
1711
<div>{'header:' + header}</div>
1812
<Nav />
19-
<Script id="client-script">{`;`}</Script>
2013
</div>
2114
)
2215
}

‎test/integration/react-server-components/test/index.test.js

+11-8
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
/* eslint-disable jest/no-commented-out-tests */
33
import { join } from 'path'
44
import fs from 'fs-extra'
5-
import { File, runDevSuite, runProdSuite } from 'next-test-utils'
5+
import { runDevSuite, runProdSuite } from 'next-test-utils'
66
import rsc from './rsc'
77

88
const appDir = join(__dirname, '../basic')
9-
const nextConfig = new File(join(appDir, 'next.config.js'))
9+
const nodeArgs = [
10+
'-r',
11+
join(appDir, '../../../lib/react-channel-require-hook.js'),
12+
]
1013

1114
/* TODO: support edge runtime in the future
1215
const edgeRuntimeBasicSuite = {
@@ -96,14 +99,14 @@ const nodejsRuntimeBasicSuite = {
9699
})
97100
}
98101
},
99-
beforeAll: () => {},
100-
afterAll: () => {
101-
nextConfig.restore()
102-
},
102+
}
103+
104+
const options = {
105+
nodeArgs,
103106
env: {
104107
__NEXT_REACT_CHANNEL: 'exp',
105108
},
106109
}
107110

108-
runDevSuite('Node.js runtime', appDir, nodejsRuntimeBasicSuite)
109-
runProdSuite('Node.js runtime', appDir, nodejsRuntimeBasicSuite)
111+
runDevSuite('Node.js runtime', appDir, nodejsRuntimeBasicSuite, options)
112+
runProdSuite('Node.js runtime', appDir, nodejsRuntimeBasicSuite, options)

‎test/integration/react-server-components/test/rsc.js

+89-95
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,21 @@ export default function (context, { runtime, env }) {
3434
})
3535

3636
// TODO: support RSC index route
37-
it.skip('should render server components correctly', async () => {
37+
it('should render server components correctly', async () => {
3838
const homeHTML = await renderViaHTTP(context.appPort, '/', null, {
3939
headers: {
4040
'x-next-test-client': 'test-util',
4141
},
4242
})
4343

44-
const browser = await webdriver(context.appPort, '/')
45-
const scriptTagContent = await browser.elementById('client-script').text()
4644
// should have only 1 DOCTYPE
4745
expect(homeHTML).toMatch(/^<!DOCTYPE html><html/)
48-
expect(homeHTML).toMatch('<meta name="rsc-title" content="index"/>')
46+
// TODO: support next/head
47+
// expect(homeHTML).toMatch('<meta name="rsc-title" content="index"/>')
4948
expect(homeHTML).toContain('component:index.server')
50-
expect(homeHTML).toContain('env:env_var_test')
49+
// TODO: support env
50+
// expect(homeHTML).toContain('env:env_var_test')
5151
expect(homeHTML).toContain('header:test-util')
52-
expect(homeHTML).toMatch(/<\/body><\/html>$/)
53-
54-
expect(scriptTagContent).toBe(';')
5552

5653
const inlineFlightContents = []
5754
const $ = cheerio.load(homeHTML)
@@ -150,104 +147,101 @@ export default function (context, { runtime, env }) {
150147
expect(dynamicRoute1HTML).toContain('router pathname: /routes/[dynamic]')
151148
})
152149

153-
// FIXME: chunks missing in prod mode
154-
if (env === 'dev') {
155-
it('should be able to navigate between rsc pages', async () => {
156-
const browser = await webdriver(context.appPort, '/root')
157-
158-
await browser.waitForElementByCss('#goto-next-link').click()
159-
await new Promise((res) => setTimeout(res, 1000))
160-
expect(await browser.url()).toBe(
161-
`http://localhost:${context.appPort}/next-api/link`
162-
)
163-
await browser.waitForElementByCss('#goto-home').click()
164-
await new Promise((res) => setTimeout(res, 1000))
165-
expect(await browser.url()).toBe(
166-
`http://localhost:${context.appPort}/root`
167-
)
168-
const content = await browser.elementByCss('body').text()
169-
expect(content).toContain('component:root.server')
170-
171-
await browser.waitForElementByCss('#goto-streaming-rsc').click()
150+
it('should be able to navigate between rsc pages', async () => {
151+
const browser = await webdriver(context.appPort, '/root')
172152

173-
// Wait for navigation and streaming to finish.
174-
await check(
175-
() => browser.elementByCss('#content').text(),
176-
'next_streaming_data'
177-
)
178-
expect(await browser.url()).toBe(
179-
`http://localhost:${context.appPort}/streaming-rsc`
180-
)
181-
})
153+
await browser.waitForElementByCss('#goto-next-link').click()
154+
await new Promise((res) => setTimeout(res, 1000))
155+
expect(await browser.url()).toBe(
156+
`http://localhost:${context.appPort}/next-api/link`
157+
)
158+
await browser.waitForElementByCss('#goto-home').click()
159+
await new Promise((res) => setTimeout(res, 1000))
160+
expect(await browser.url()).toBe(`http://localhost:${context.appPort}/root`)
161+
const content = await browser.elementByCss('body').text()
162+
expect(content).toContain('component:root.server')
163+
164+
await browser.waitForElementByCss('#goto-streaming-rsc').click()
165+
166+
// Wait for navigation and streaming to finish.
167+
await check(
168+
() => browser.elementByCss('#content').text(),
169+
'next_streaming_data'
170+
)
171+
expect(await browser.url()).toBe(
172+
`http://localhost:${context.appPort}/streaming-rsc`
173+
)
174+
})
182175

183-
it('should handle streaming server components correctly', async () => {
184-
const browser = await webdriver(context.appPort, '/streaming-rsc')
185-
const content = await browser.eval(
186-
`document.querySelector('#content').innerText`
187-
)
188-
expect(content).toMatchInlineSnapshot('"next_streaming_data"')
189-
})
176+
it('should handle streaming server components correctly', async () => {
177+
const browser = await webdriver(context.appPort, '/streaming-rsc')
178+
const content = await browser.eval(
179+
`document.querySelector('#content').innerText`
180+
)
181+
expect(content).toMatchInlineSnapshot('"next_streaming_data"')
182+
})
190183

191-
it('should support next/link in server components', async () => {
192-
const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link')
193-
const linkText = getNodeBySelector(
194-
linkHTML,
195-
'body > div > a[href="/root"]'
196-
).text()
184+
it('should support next/link in server components', async () => {
185+
const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link')
186+
const linkText = getNodeBySelector(
187+
linkHTML,
188+
'body > div > a[href="/root"]'
189+
).text()
197190

198-
expect(linkText).toContain('home')
191+
expect(linkText).toContain('home')
199192

200-
const browser = await webdriver(context.appPort, '/next-api/link')
193+
const browser = await webdriver(context.appPort, '/next-api/link')
201194

202-
// We need to make sure the app is fully hydrated before clicking, otherwise
203-
// it will be a full redirection instead of being taken over by the next
204-
// router. This timeout prevents it being flaky caused by fast refresh's
205-
// rebuilding event.
206-
await new Promise((res) => setTimeout(res, 1000))
207-
await browser.eval('window.beforeNav = 1')
195+
// We need to make sure the app is fully hydrated before clicking, otherwise
196+
// it will be a full redirection instead of being taken over by the next
197+
// router. This timeout prevents it being flaky caused by fast refresh's
198+
// rebuilding event.
199+
await new Promise((res) => setTimeout(res, 1000))
200+
await browser.eval('window.beforeNav = 1')
208201

209-
await browser.waitForElementByCss('#next_id').click()
210-
await check(() => browser.elementByCss('#query').text(), 'query:1')
202+
await browser.waitForElementByCss('#next_id').click()
203+
await check(() => browser.elementByCss('#query').text(), 'query:1')
211204

212-
await browser.waitForElementByCss('#next_id').click()
213-
await check(() => browser.elementByCss('#query').text(), 'query:2')
205+
await browser.waitForElementByCss('#next_id').click()
206+
await check(() => browser.elementByCss('#query').text(), 'query:2')
214207

208+
if (env === 'dev') {
215209
expect(await browser.eval('window.beforeNav')).toBe(1)
216-
})
210+
}
211+
})
217212

218-
it('should refresh correctly with next/link', async () => {
219-
// Select the button which is not hidden but rendered
220-
const selector = '#goto-next-link'
221-
let hasFlightRequest = false
222-
const browser = await webdriver(context.appPort, '/root', {
223-
beforePageLoad(page) {
224-
page.on('request', (request) => {
225-
const url = request.url()
226-
if (/\?__flight__=1/.test(url)) {
227-
hasFlightRequest = true
228-
}
229-
})
230-
},
231-
})
232-
233-
// wait for hydration
234-
await new Promise((res) => setTimeout(res, 1000))
235-
if (env === 'dev') {
236-
expect(hasFlightRequest).toBe(false)
237-
}
238-
await browser.elementByCss(selector).click()
239-
240-
// wait for re-hydration
241-
if (env === 'dev') {
242-
await check(
243-
() => (hasFlightRequest ? 'success' : hasFlightRequest),
244-
'success'
245-
)
246-
}
247-
const refreshText = await browser.elementByCss(selector).text()
248-
expect(refreshText).toBe('next link')
213+
it('should refresh correctly with next/link', async () => {
214+
// Select the button which is not hidden but rendered
215+
const selector = '#goto-next-link'
216+
let hasFlightRequest = false
217+
const browser = await webdriver(context.appPort, '/root', {
218+
beforePageLoad(page) {
219+
page.on('request', (request) => {
220+
const url = request.url()
221+
if (/\?__flight__=1/.test(url)) {
222+
hasFlightRequest = true
223+
}
224+
})
225+
},
249226
})
250-
}
227+
228+
// wait for hydration
229+
await new Promise((res) => setTimeout(res, 1000))
230+
if (env === 'dev') {
231+
expect(hasFlightRequest).toBe(false)
232+
}
233+
await browser.elementByCss(selector).click()
234+
235+
// wait for re-hydration
236+
if (env === 'dev') {
237+
await check(
238+
() => (hasFlightRequest ? 'success' : hasFlightRequest),
239+
'success'
240+
)
241+
}
242+
const refreshText = await browser.elementByCss(selector).text()
243+
expect(refreshText).toBe('next link')
244+
})
251245

252246
it('should escape streaming data correctly', async () => {
253247
const browser = await webdriver(context.appPort, '/escaping-rsc')
@@ -347,7 +341,7 @@ export default function (context, { runtime, env }) {
347341
expect(getNodeBySelector(pageUnknownHTML, id).text()).toBe(content)
348342
})
349343

350-
it.skip('should support streaming for flight response', async () => {
344+
it('should support streaming for flight response', async () => {
351345
await fetchViaHTTP(context.appPort, '/?__flight__=1').then(
352346
async (response) => {
353347
const result = await resolveStreamResponse(response)

‎test/lib/next-test-utils.js

+3
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,7 @@ function runSuite(suiteName, context, options) {
768768
stderr: true,
769769
stdout: true,
770770
env: options.env || {},
771+
nodeArgs: options.nodeArgs,
771772
})
772773
context.stdout = stdout
773774
context.stderr = stderr
@@ -776,13 +777,15 @@ function runSuite(suiteName, context, options) {
776777
onStderr,
777778
onStdout,
778779
env: options.env || {},
780+
nodeArgs: options.nodeArgs,
779781
})
780782
} else if (env === 'dev') {
781783
context.appPort = await findPort()
782784
context.server = await launchApp(context.appDir, context.appPort, {
783785
onStderr,
784786
onStdout,
785787
env: options.env || {},
788+
nodeArgs: options.nodeArgs,
786789
})
787790
}
788791
})

0 commit comments

Comments
 (0)
Please sign in to comment.