Support shared component with next built-in client components (#35975)
Fixes #35449 Include the shared components (from source code) is in client bundles, previously we lost them so that the client components imported by them are lost in module graph * let flight server loader apply to all pages and imported modules (except node_modules at the moment) * if it's a shared component from source code, include it in client bundle * ignore handling node_modules at the moment (due to the limitation support of esm imports with RSC) ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [x] Errors have helpful link attached, see `contributing.md`
This commit is contained in:
parent
51d7153ea8
commit
630bf80a22
9 changed files with 91 additions and 64 deletions
|
@ -980,6 +980,12 @@ export default async function getBaseWebpackConfig(
|
|||
},
|
||||
}
|
||||
|
||||
const rscCodeCondition = {
|
||||
test: serverComponentsRegex,
|
||||
// only apply to the pages as the begin process of rsc loaders
|
||||
include: [dir, /next[\\/]dist[\\/]pages/],
|
||||
}
|
||||
|
||||
let webpackConfig: webpack.Configuration = {
|
||||
parallelism: Number(process.env.NEXT_WEBPACK_PARALLELISM) || undefined,
|
||||
externals: targetWeb
|
||||
|
@ -1202,32 +1208,24 @@ export default async function getBaseWebpackConfig(
|
|||
? [
|
||||
// RSC server compilation loaders
|
||||
{
|
||||
...codeCondition,
|
||||
...rscCodeCondition,
|
||||
use: {
|
||||
loader: 'next-flight-server-loader',
|
||||
options: {
|
||||
pageExtensions: rawPageExtensions,
|
||||
extensions: rawPageExtensions,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: codeCondition.test,
|
||||
resourceQuery: /__sc_client__/,
|
||||
use: {
|
||||
loader: 'next-flight-client-loader',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
// RSC client compilation loaders
|
||||
{
|
||||
...codeCondition,
|
||||
test: serverComponentsRegex,
|
||||
...rscCodeCondition,
|
||||
use: {
|
||||
loader: 'next-flight-server-loader',
|
||||
options: {
|
||||
client: 1,
|
||||
pageExtensions: rawPageExtensions,
|
||||
extensions: rawPageExtensions,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -87,7 +87,6 @@ async function parseModuleInfo(
|
|||
} = node
|
||||
// exports.xxx = xxx
|
||||
if (
|
||||
left &&
|
||||
left.object &&
|
||||
left.type === 'MemberExpression' &&
|
||||
left.object.type === 'Identifier' &&
|
||||
|
|
|
@ -3,14 +3,14 @@ import { buildExports } from './utils'
|
|||
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'avif']
|
||||
|
||||
export const createClientComponentFilter = (pageExtensions: string[]) => {
|
||||
export const createClientComponentFilter = (extensions: string[]) => {
|
||||
// Special cases for Next.js APIs that are considered as client components:
|
||||
// - .client.[ext]
|
||||
// - next built-in client components
|
||||
// - .[imageExt]
|
||||
const regex = new RegExp(
|
||||
'(' +
|
||||
`\\.client(\\.(${pageExtensions.join('|')}))?|` +
|
||||
`\\.client(\\.(${extensions.join('|')}))?|` +
|
||||
`next/(link|image)(\\.js)?|` +
|
||||
`\\.(${imageExtensions.join('|')})` +
|
||||
')$'
|
||||
|
@ -19,26 +19,40 @@ export const createClientComponentFilter = (pageExtensions: string[]) => {
|
|||
return (importSource: string) => regex.test(importSource)
|
||||
}
|
||||
|
||||
export const createServerComponentFilter = (pageExtensions: string[]) => {
|
||||
const regex = new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?$`)
|
||||
export const createServerComponentFilter = (extensions: string[]) => {
|
||||
const regex = new RegExp(`\\.server(\\.(${extensions.join('|')}))?$`)
|
||||
return (importSource: string) => regex.test(importSource)
|
||||
}
|
||||
|
||||
function createFlightServerRequest(request: string, extensions: string[]) {
|
||||
return `next-flight-server-loader?${JSON.stringify({
|
||||
extensions,
|
||||
})}!${request}`
|
||||
}
|
||||
|
||||
function hasFlightLoader(request: string, type: 'client' | 'server') {
|
||||
return request.includes(`next-flight-${type}-loader`)
|
||||
}
|
||||
|
||||
async function parseModuleInfo({
|
||||
resourcePath,
|
||||
source,
|
||||
extensions,
|
||||
isClientCompilation,
|
||||
isServerComponent,
|
||||
isClientComponent,
|
||||
resolver,
|
||||
}: {
|
||||
resourcePath: string
|
||||
source: string
|
||||
isClientCompilation: boolean
|
||||
extensions: string[]
|
||||
isServerComponent: (name: string) => boolean
|
||||
isClientComponent: (name: string) => boolean
|
||||
resolver: (req: string) => Promise<string>
|
||||
}): Promise<{
|
||||
source: string
|
||||
imports: string
|
||||
imports: string[]
|
||||
isEsm: boolean
|
||||
__N_SSP: boolean
|
||||
pageRuntime: 'edge' | 'nodejs' | null
|
||||
|
@ -50,7 +64,7 @@ async function parseModuleInfo({
|
|||
const { type, body } = ast
|
||||
let transformedSource = ''
|
||||
let lastIndex = 0
|
||||
let imports = ''
|
||||
let imports = []
|
||||
let __N_SSP = false
|
||||
let pageRuntime = null
|
||||
|
||||
|
@ -61,6 +75,16 @@ async function parseModuleInfo({
|
|||
switch (node.type) {
|
||||
case 'ImportDeclaration':
|
||||
const importSource = node.source.value
|
||||
const resolvedPath = await resolver(importSource)
|
||||
const isNodeModuleImport = resolvedPath.includes('/node_modules/')
|
||||
|
||||
// matching node_module package but excluding react cores since react is required to be shared
|
||||
const isReactImports = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
].includes(importSource)
|
||||
|
||||
if (!isClientCompilation) {
|
||||
// Server compilation for .server.js.
|
||||
if (isServerComponent(importSource)) {
|
||||
|
@ -73,45 +97,37 @@ async function parseModuleInfo({
|
|||
)
|
||||
|
||||
if (isClientComponent(importSource)) {
|
||||
// A client component. It should be loaded as module reference.
|
||||
transformedSource += importDeclarations
|
||||
transformedSource += JSON.stringify(`${importSource}?__sc_client__`)
|
||||
imports += `require(${JSON.stringify(importSource)})\n`
|
||||
} else {
|
||||
// FIXME
|
||||
// case: 'react'
|
||||
// Avoid module resolution error like Cannot find `./?__rsc_server__` in react/package.json
|
||||
|
||||
// cases: 'react/jsx-runtime', 'react/jsx-dev-runtime'
|
||||
// This is a special case to avoid the Duplicate React error.
|
||||
// Since we already include React in the SSR runtime,
|
||||
// here we can't create a new module with the ?__rsc_server__ query.
|
||||
if (
|
||||
['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'].includes(
|
||||
importSource
|
||||
transformedSource += JSON.stringify(
|
||||
`next-flight-client-loader!${importSource}`
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// A shared component. It should be handled as a server
|
||||
// component.
|
||||
imports.push(importSource)
|
||||
} else {
|
||||
// A shared component. It should be handled as a server component.
|
||||
const serverImportSource = isReactImports
|
||||
? importSource
|
||||
: createFlightServerRequest(importSource, extensions)
|
||||
transformedSource += importDeclarations
|
||||
transformedSource += JSON.stringify(`${importSource}?__sc_server__`)
|
||||
transformedSource += JSON.stringify(serverImportSource)
|
||||
|
||||
// TODO: support handling RSC components from node_modules
|
||||
if (!isNodeModuleImport) {
|
||||
imports.push(importSource)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For the client compilation, we skip all modules imports but
|
||||
// always keep client components in the bundle. All client components
|
||||
// always keep client/shared components in the bundle. All client components
|
||||
// have to be imported from either server or client components.
|
||||
if (
|
||||
!(
|
||||
isClientComponent(importSource) || isServerComponent(importSource)
|
||||
)
|
||||
isServerComponent(importSource) ||
|
||||
hasFlightLoader(importSource, 'server') ||
|
||||
// TODO: support handling RSC components from node_modules
|
||||
isNodeModuleImport
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
imports += `require(${JSON.stringify(importSource)})\n`
|
||||
imports.push(importSource)
|
||||
}
|
||||
|
||||
lastIndex = node.source.span.end
|
||||
|
@ -158,23 +174,33 @@ export default async function transformSource(
|
|||
this: any,
|
||||
source: string
|
||||
): Promise<string> {
|
||||
const { client: isClientCompilation, pageExtensions } = this.getOptions()
|
||||
const { resourcePath, resourceQuery } = this
|
||||
const { client: isClientCompilation, extensions } = this.getOptions()
|
||||
const { resourcePath, resolve: resolveFn, context } = this
|
||||
|
||||
const resolver = (req: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveFn(context, req, (err: any, result: string) => {
|
||||
if (err) return reject(err)
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof source !== 'string') {
|
||||
throw new Error('Expected source to have been transformed to a string.')
|
||||
}
|
||||
|
||||
const isServerComponent = createServerComponentFilter(pageExtensions)
|
||||
const isClientComponent = createClientComponentFilter(pageExtensions)
|
||||
const isServerComponent = createServerComponentFilter(extensions)
|
||||
const isClientComponent = createClientComponentFilter(extensions)
|
||||
const hasAppliedFlightServerLoader = this.loaders.some((loader: any) => {
|
||||
return hasFlightLoader(loader.path, 'server')
|
||||
})
|
||||
const isServerExt = isServerComponent(resourcePath)
|
||||
|
||||
if (!isClientCompilation) {
|
||||
// We only apply the loader to server components, or shared components that
|
||||
// are imported by a server component.
|
||||
if (
|
||||
!isServerComponent(resourcePath) &&
|
||||
resourceQuery !== '?__sc_server__'
|
||||
) {
|
||||
if (!isServerExt && !hasAppliedFlightServerLoader) {
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
@ -188,9 +214,11 @@ export default async function transformSource(
|
|||
} = await parseModuleInfo({
|
||||
resourcePath,
|
||||
source,
|
||||
extensions,
|
||||
isClientCompilation,
|
||||
isServerComponent,
|
||||
isClientComponent,
|
||||
resolver,
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -208,8 +236,12 @@ export default async function transformSource(
|
|||
const rscExports: any = {
|
||||
__next_rsc__: `{
|
||||
__webpack_require__,
|
||||
_: () => {\n${imports}\n},
|
||||
server: ${isServerComponent(resourcePath) ? 'true' : 'false'}
|
||||
_: () => {
|
||||
${imports
|
||||
.map((importSource) => `require('${importSource}');`)
|
||||
.join('\n')}
|
||||
},
|
||||
server: ${isServerExt ? 'true' : 'false'}
|
||||
}`,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import moment from 'moment'
|
||||
import nonIsomorphicText from 'non-isomorphic-text'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<div>date:{moment().toString()}</div>
|
||||
<div>{nonIsomorphicText()}</div>
|
||||
<div>date:{nonIsomorphicText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Nav from '../components/nav.server'
|
||||
import Nav from '../components/nav'
|
||||
|
||||
const envVar = process.env.ENV_VAR_TEST
|
||||
const headerKey = 'x-next-test-client'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Link from 'next/link'
|
||||
import Nav from '../../components/nav.server'
|
||||
import Nav from '../../components/nav'
|
||||
|
||||
export default function LinkPage({ router }) {
|
||||
const { query } = router
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Suspense } from 'react'
|
||||
import Nav from '../components/nav.server'
|
||||
import Nav from '../components/nav'
|
||||
|
||||
let result
|
||||
let promise
|
||||
|
|
|
@ -192,7 +192,7 @@ export default function (context, { runtime, env }) {
|
|||
.readFileSync(join(distServerDir, 'external-imports.js'))
|
||||
.toString()
|
||||
|
||||
expect(bundle).not.toContain('moment')
|
||||
expect(bundle).not.toContain('non-isomorphic-text')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue