Fix client component hydration (#37134)

This PR makes sure that chunks of client components can be loaded via `__webpack_chunk_load__`, and hydrated correctly inside `viewsDir`.

Side note: we have to get rid of `[contenthash]` from the chunk filename because of a conflict currently which can be resolved later.

## Bug

- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `yarn lint`
This commit is contained in:
Shu Ding 2022-05-24 16:54:26 +02:00 committed by GitHub
parent 6736041d51
commit 2a89c1926d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 241 additions and 195 deletions

View file

@ -108,7 +108,7 @@ import { MiddlewareManifest } from './webpack/plugins/middleware-plugin'
import { recursiveCopy } from '../lib/recursive-copy'
import { recursiveReadDir } from '../lib/recursive-readdir'
import { lockfilePatchPromise, teardownTraceSubscriber } from './swc'
import { injectedClientEntries } from './webpack/plugins/flight-manifest-plugin'
import { injectedClientEntries } from './webpack/plugins/client-entry-plugin'
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
import { flatReaddir } from '../lib/flat-readdir'

View file

@ -46,6 +46,7 @@ import { WellKnownErrorsPlugin } from './webpack/plugins/wellknown-errors-plugin
import { regexLikeCss } from './webpack/config/blocks/css'
import { CopyFilePlugin } from './webpack/plugins/copy-file-plugin'
import { FlightManifestPlugin } from './webpack/plugins/flight-manifest-plugin'
import { ClientEntryPlugin } from './webpack/plugins/client-entry-plugin'
import {
Feature,
SWC_TARGET_TRIPLE,
@ -1016,6 +1017,7 @@ export default async function getBaseWebpackConfig(
? {
// We have to use the names here instead of hashes to ensure the consistency between compilers.
moduleIds: 'named',
chunkIds: 'named',
}
: {}),
splitChunks: ((): webpack.Options.SplitChunksOptions | false => {
@ -1182,7 +1184,7 @@ export default async function getBaseWebpackConfig(
? `[name].js`
: `../[name].js`
: `static/chunks/${isDevFallback ? 'fallback/' : ''}[name]${
dev ? '' : '-[contenthash]'
dev ? '' : viewsDir ? '' : '-[contenthash]'
}.js`,
library: isClient || isEdgeServer ? '_N_E' : undefined,
libraryTarget: isClient || isEdgeServer ? 'assign' : 'commonjs2',
@ -1641,12 +1643,16 @@ export default async function getBaseWebpackConfig(
},
}),
hasServerComponents &&
!isClient &&
new FlightManifestPlugin({
dev,
pageExtensions: rawPageExtensions,
isEdgeServer,
}),
(isClient
? new FlightManifestPlugin({
dev,
viewsDir: !!config.experimental.viewsDir,
pageExtensions: rawPageExtensions,
})
: new ClientEntryPlugin({
dev,
isEdgeServer,
})),
!dev &&
isClient &&
new TelemetryPlugin(

View file

@ -0,0 +1,192 @@
import { stringify } from 'querystring'
import { webpack } from 'next/dist/compiled/webpack/webpack'
import {
EDGE_RUNTIME_WEBPACK,
NEXT_CLIENT_SSR_ENTRY_SUFFIX,
} from '../../../shared/lib/constants'
import { clientComponentRegex } from '../loaders/utils'
import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path'
import { denormalizePagePath } from '../../../shared/lib/page-path/denormalize-page-path'
import {
getInvalidator,
entries,
} from '../../../server/dev/on-demand-entry-handler'
import { getPageStaticInfo } from '../../analysis/get-page-static-info'
type Options = {
dev: boolean
isEdgeServer: boolean
}
const PLUGIN_NAME = 'ClientEntryPlugin'
export const injectedClientEntries = new Map()
export class ClientEntryPlugin {
dev: boolean = false
isEdgeServer: boolean
constructor(options: Options) {
if (typeof options.dev === 'boolean') {
this.dev = options.dev
}
this.isEdgeServer = options.isEdgeServer
}
apply(compiler: any) {
compiler.hooks.compilation.tap(
PLUGIN_NAME,
(compilation: any, { normalModuleFactory }: any) => {
compilation.dependencyFactories.set(
(webpack as any).dependencies.ModuleDependency,
normalModuleFactory
)
compilation.dependencyTemplates.set(
(webpack as any).dependencies.ModuleDependency,
new (webpack as any).dependencies.NullDependency.Template()
)
}
)
// Only for webpack 5
compiler.hooks.finishMake.tapAsync(
PLUGIN_NAME,
async (compilation: any, callback: any) => {
this.createClientEndpoints(compilation, callback)
}
)
}
async createClientEndpoints(compilation: any, callback: () => void) {
const context = (this as any).context
const promises: any = []
// For each SC server compilation entry, we need to create its corresponding
// client component entry.
for (const [name, entry] of compilation.entries.entries()) {
// Check if the page entry is a server component or not.
const entryDependency = entry.dependencies?.[0]
const request = entryDependency?.request
if (request && entry.options?.layer === 'sc_server') {
const visited = new Set()
const clientComponentImports: string[] = []
function filterClientComponents(dependency: any) {
const module = compilation.moduleGraph.getResolvedModule(dependency)
if (!module) return
if (visited.has(module.userRequest)) return
visited.add(module.userRequest)
if (clientComponentRegex.test(module.userRequest)) {
clientComponentImports.push(module.userRequest)
}
compilation.moduleGraph
.getOutgoingConnections(module)
.forEach((connection: any) => {
filterClientComponents(connection.dependency)
})
}
// Traverse the module graph to find all client components.
filterClientComponents(entryDependency)
const entryModule =
compilation.moduleGraph.getResolvedModule(entryDependency)
const routeInfo = entryModule.buildInfo.route || {
page: denormalizePagePath(name.replace(/^pages/, '')),
absolutePagePath: entryModule.resource,
}
// Parse gSSP and gSP exports from the page source.
const pageStaticInfo = this.isEdgeServer
? {}
: await getPageStaticInfo({
pageFilePath: routeInfo.absolutePagePath,
nextConfig: {},
isDev: this.dev,
})
const clientLoader = `next-flight-client-entry-loader?${stringify({
modules: clientComponentImports,
runtime: this.isEdgeServer ? 'edge' : 'nodejs',
ssr: pageStaticInfo.ssr,
// Adding name here to make the entry key unique.
name,
})}!`
const bundlePath = 'pages' + normalizePagePath(routeInfo.page)
// Inject the entry to the client compiler.
if (this.dev) {
const pageKey = 'client' + routeInfo.page
if (!entries[pageKey]) {
entries[pageKey] = {
bundlePath,
absolutePagePath: routeInfo.absolutePagePath,
clientLoader,
dispose: false,
lastActiveTime: Date.now(),
} as any
const invalidator = getInvalidator()
if (invalidator) {
invalidator.invalidate()
}
}
} else {
injectedClientEntries.set(
bundlePath,
`next-client-pages-loader?${stringify({
isServerComponent: true,
page: denormalizePagePath(bundlePath.replace(/^pages/, '')),
absolutePagePath: clientLoader,
})}!` + clientLoader
)
}
// Inject the entry to the server compiler.
const clientComponentEntryDep = (
webpack as any
).EntryPlugin.createDependency(
clientLoader,
name + NEXT_CLIENT_SSR_ENTRY_SUFFIX
)
promises.push(
new Promise<void>((res, rej) => {
compilation.addEntry(
context,
clientComponentEntryDep,
this.isEdgeServer
? {
name: name + NEXT_CLIENT_SSR_ENTRY_SUFFIX,
library: {
name: ['self._CLIENT_ENTRY'],
type: 'assign',
},
runtime: EDGE_RUNTIME_WEBPACK,
asyncChunks: false,
}
: {
name: name + NEXT_CLIENT_SSR_ENTRY_SUFFIX,
runtime: 'webpack-runtime',
},
(err: any) => {
if (err) {
rej(err)
} else {
res()
}
}
)
})
)
}
}
Promise.all(promises)
.then(() => callback())
.catch(callback)
}
}

View file

@ -5,21 +5,9 @@
* LICENSE file in the root directory of this source tree.
*/
import { stringify } from 'querystring'
import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
import {
MIDDLEWARE_FLIGHT_MANIFEST,
EDGE_RUNTIME_WEBPACK,
NEXT_CLIENT_SSR_ENTRY_SUFFIX,
} from '../../../shared/lib/constants'
import { MIDDLEWARE_FLIGHT_MANIFEST } from '../../../shared/lib/constants'
import { clientComponentRegex } from '../loaders/utils'
import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path'
import { denormalizePagePath } from '../../../shared/lib/page-path/denormalize-page-path'
import {
getInvalidator,
entries,
} from '../../../server/dev/on-demand-entry-handler'
import { getPageStaticInfo } from '../../analysis/get-page-static-info'
// This is the module that will be used to anchor all client references to.
// I.e. it will have all the client files as async deps from this point on.
@ -30,28 +18,23 @@ import { getPageStaticInfo } from '../../analysis/get-page-static-info'
type Options = {
dev: boolean
viewsDir: boolean
pageExtensions: string[]
isEdgeServer: boolean
}
const PLUGIN_NAME = 'FlightManifestPlugin'
let edgeFlightManifest = {}
let nodeFlightManifest = {}
export const injectedClientEntries = new Map()
export class FlightManifestPlugin {
dev: boolean = false
pageExtensions: string[]
isEdgeServer: boolean
viewsDir: boolean = false
constructor(options: Options) {
if (typeof options.dev === 'boolean') {
this.dev = options.dev
}
this.viewsDir = options.viewsDir
this.pageExtensions = options.pageExtensions
this.isEdgeServer = options.isEdgeServer
}
apply(compiler: any) {
@ -69,14 +52,6 @@ export class FlightManifestPlugin {
}
)
// Only for webpack 5
compiler.hooks.finishMake.tapAsync(
PLUGIN_NAME,
async (compilation: any, callback: any) => {
this.createClientEndpoints(compilation, callback)
}
)
compiler.hooks.make.tap(PLUGIN_NAME, (compilation: any) => {
compilation.hooks.processAssets.tap(
{
@ -89,143 +64,12 @@ export class FlightManifestPlugin {
})
}
async createClientEndpoints(compilation: any, callback: () => void) {
const context = (this as any).context
const promises: any = []
// For each SC server compilation entry, we need to create its corresponding
// client component entry.
for (const [name, entry] of compilation.entries.entries()) {
// Check if the page entry is a server component or not.
const entryDependency = entry.dependencies?.[0]
const request = entryDependency?.request
if (request && entry.options?.layer === 'sc_server') {
const visited = new Set()
const clientComponentImports: string[] = []
function filterClientComponents(dependency: any) {
const module = compilation.moduleGraph.getResolvedModule(dependency)
if (!module) return
if (visited.has(module.userRequest)) return
visited.add(module.userRequest)
if (clientComponentRegex.test(module.userRequest)) {
clientComponentImports.push(module.userRequest)
}
compilation.moduleGraph
.getOutgoingConnections(module)
.forEach((connection: any) => {
filterClientComponents(connection.dependency)
})
}
// Traverse the module graph to find all client components.
filterClientComponents(entryDependency)
const entryModule =
compilation.moduleGraph.getResolvedModule(entryDependency)
const routeInfo = entryModule.buildInfo.route || {
page: denormalizePagePath(name.replace(/^pages/, '')),
absolutePagePath: entryModule.resource,
}
// Parse gSSP and gSP exports from the page source.
const pageStaticInfo = this.isEdgeServer
? {}
: await getPageStaticInfo({
pageFilePath: routeInfo.absolutePagePath,
nextConfig: {},
isDev: this.dev,
})
const clientLoader = `next-flight-client-entry-loader?${stringify({
modules: clientComponentImports,
runtime: this.isEdgeServer ? 'edge' : 'nodejs',
ssr: pageStaticInfo.ssr,
// Adding name here to make the entry key unique.
name,
})}!`
const bundlePath = 'pages' + normalizePagePath(routeInfo.page)
// Inject the entry to the client compiler.
if (this.dev) {
const pageKey = 'client' + routeInfo.page
if (!entries[pageKey]) {
entries[pageKey] = {
bundlePath,
absolutePagePath: routeInfo.absolutePagePath,
clientLoader,
dispose: false,
lastActiveTime: Date.now(),
} as any
const invalidator = getInvalidator()
if (invalidator) {
invalidator.invalidate()
}
}
} else {
injectedClientEntries.set(
bundlePath,
`next-client-pages-loader?${stringify({
isServerComponent: true,
page: denormalizePagePath(bundlePath.replace(/^pages/, '')),
absolutePagePath: clientLoader,
})}!` + clientLoader
)
}
// Inject the entry to the server compiler.
const clientComponentEntryDep = (
webpack as any
).EntryPlugin.createDependency(
clientLoader,
name + NEXT_CLIENT_SSR_ENTRY_SUFFIX
)
promises.push(
new Promise<void>((res, rej) => {
compilation.addEntry(
context,
clientComponentEntryDep,
this.isEdgeServer
? {
name: name + NEXT_CLIENT_SSR_ENTRY_SUFFIX,
library: {
name: ['self._CLIENT_ENTRY'],
type: 'assign',
},
runtime: EDGE_RUNTIME_WEBPACK,
asyncChunks: false,
}
: {
name: name + NEXT_CLIENT_SSR_ENTRY_SUFFIX,
runtime: 'webpack-runtime',
},
(err: any) => {
if (err) {
rej(err)
} else {
res()
}
}
)
})
)
}
}
Promise.all(promises)
.then(() => callback())
.catch(callback)
}
createAsset(assets: any, compilation: any) {
const manifest: any = {}
const viewsDir = this.viewsDir
compilation.chunkGroups.forEach((chunkGroup: any) => {
function recordModule(id: string, _chunk: any, mod: any) {
function recordModule(chunk: any, id: string, mod: any) {
const resource = mod.resource
// TODO: Hook into deps instead of the target module.
@ -258,7 +102,7 @@ export class FlightManifestPlugin {
moduleExports[name] = {
id: id.replace(/^\(sc_server\)\//, ''),
name,
chunks: [],
chunks: viewsDir ? chunk.ids : [],
}
}
})
@ -277,33 +121,22 @@ export class FlightManifestPlugin {
modId = modId.split('?')[0]
// Remove the loader prefix.
modId = modId.split('next-flight-client-loader.js!')[1] || modId
modId = modId.replace(/^\(sc_server\)\//, '')
recordModule(chunk, modId, mod)
recordModule(modId, chunk, mod)
// If this is a concatenation, register each child to the parent ID.
if (mod.modules) {
mod.modules.forEach((concatenatedMod: any) => {
recordModule(modId, chunk, concatenatedMod)
recordModule(chunk, modId, concatenatedMod)
})
}
}
})
})
// With switchable runtime, we need to emit the manifest files for both
// runtimes.
if (this.isEdgeServer) {
edgeFlightManifest = manifest
} else {
nodeFlightManifest = manifest
}
const mergedManifest = {
...nodeFlightManifest,
...edgeFlightManifest,
}
const file =
(!this.dev && !this.isEdgeServer ? '../' : '') +
MIDDLEWARE_FLIGHT_MANIFEST
const json = JSON.stringify(mergedManifest)
const file = 'server/' + MIDDLEWARE_FLIGHT_MANIFEST
const json = JSON.stringify(manifest)
assets[file + '.js'] = new sources.RawSource('self.__RSC_MANIFEST=' + json)
assets[file + '.json'] = new sources.RawSource(json)

View file

@ -5,4 +5,15 @@ window.next = {
root: true,
}
// Override chunk URL mapping in the webpack runtime
// https://github.com/webpack/webpack/blob/2738eebc7880835d88c727d364ad37f3ec557593/lib/RuntimeGlobals.js#L204
// eslint-disable-next-line no-undef
const getChunkScriptFilename = __webpack_require__.u
// eslint-disable-next-line no-undef
__webpack_require__.u = (chunkId) => {
return getChunkScriptFilename(chunkId) || `static/chunks/${chunkId}.js`
}
hydrate()

View file

@ -486,6 +486,8 @@ export async function renderToHTML(
// @ts-ignore
globalThis.__webpack_require__ =
ComponentMod.__next_rsc__.__webpack_require__
// @ts-ignore
globalThis.__webpack_chunk_load__ = () => Promise.resolve()
Component = createServerComponentRenderer(Component, {
cachePrefix: pathname + (search ? `?${search}` : ''),

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
export default function ClientComponentRoute() {
const [count, setCount] = useState(0)
useEffect(() => {

View file

@ -240,9 +240,9 @@ describe('views dir', () => {
)
})
// TODO: Implement hydration
it.skip('should serve client-side', async () => {
it('should serve client-side', async () => {
const browser = await webdriver(next.url, '/client-component-route')
// After hydration count should be 1
expect(await browser.elementByCss('p').text()).toBe(
'hello from root/client-component-route. count: 1'
@ -260,15 +260,16 @@ describe('views dir', () => {
expect($('p').text()).toBe('hello from root/client-nested')
})
// TODO: Implement hydration
it.skip('should include it client-side', async () => {
it('should include it client-side', async () => {
const browser = await webdriver(next.url, '/client-nested')
// After hydration count should be 1
expect(await browser.elementByCss('h1').text()).toBe(
'Client Nested. Count: 0'
'Client Nested. Count: 1'
)
// After hydration count should be 1
expect(await browser.elementByCss('h1').text()).toBe(
expect(await browser.elementByCss('p').text()).toBe(
'hello from root/client-nested'
)
})