Skip to content

Commit

Permalink
Support shared component with next built-in client components (#35975)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
huozhi committed Apr 7, 2022
1 parent 51d7153 commit 630bf80
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 64 deletions.
22 changes: 10 additions & 12 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
},
},
Expand Down
Expand Up @@ -87,7 +87,6 @@ async function parseModuleInfo(
} = node
// exports.xxx = xxx
if (
left &&
left.object &&
left.type === 'MemberExpression' &&
left.object.type === 'Identifier' &&
Expand Down
120 changes: 76 additions & 44 deletions packages/next/build/webpack/loaders/next-flight-server-loader.ts
Expand Up @@ -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('|')})` +
')$'
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)) {
Expand All @@ -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`
transformedSource += JSON.stringify(
`next-flight-client-loader!${importSource}`
)
imports.push(importSource)
} 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
)
) {
continue
}

// A shared component. It should be handled as a server
// component.
// 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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -188,9 +214,11 @@ export default async function transformSource(
} = await parseModuleInfo({
resourcePath,
source,
extensions,
isClientCompilation,
isServerComponent,
isClientComponent,
resolver,
})

/**
Expand All @@ -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'}
}`,
}

Expand Down
@@ -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'
Expand Down
@@ -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
Expand Down
@@ -1,5 +1,5 @@
import { Suspense } from 'react'
import Nav from '../components/nav.server'
import Nav from '../components/nav'

let result
let promise
Expand Down
Expand Up @@ -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')
})
}

Expand Down

0 comments on commit 630bf80

Please sign in to comment.