Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix flight loader for shared components #34692

Merged
merged 4 commits into from Feb 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 10 additions & 16 deletions packages/next/build/webpack-config.ts
@@ -1,7 +1,6 @@
import ReactRefreshWebpackPlugin from 'next/dist/compiled/@next/react-refresh-utils/ReactRefreshWebpackPlugin'
import chalk from 'next/dist/compiled/chalk'
import crypto from 'crypto'
import { stringify } from 'querystring'
import { webpack } from 'next/dist/compiled/webpack/webpack'
import type { webpack5 } from 'next/dist/compiled/webpack/webpack'
import path, { join as pathJoin, relative as relativePath } from 'path'
Expand Down Expand Up @@ -1180,10 +1179,11 @@ export default async function getBaseWebpackConfig(
...codeCondition,
test: serverComponentsRegex,
use: {
loader: `next-flight-server-loader?${stringify({
loader: 'next-flight-server-loader',
options: {
client: 1,
pageExtensions: JSON.stringify(rawPageExtensions),
})}`,
pageExtensions: rawPageExtensions,
},
},
},
]
Expand All @@ -1195,22 +1195,16 @@ export default async function getBaseWebpackConfig(
? [
{
...codeCondition,
test: serverComponentsRegex,
use: {
loader: `next-flight-server-loader?${stringify({
pageExtensions: JSON.stringify(rawPageExtensions),
})}`,
},
},
{
...codeCondition,
test: clientComponentsRegex,
use: {
loader: 'next-flight-client-loader',
loader: 'next-flight-server-loader',
options: {
pageExtensions: rawPageExtensions,
},
},
},
{
test: /next[\\/](dist[\\/]client[\\/])?(link|image)/,
test: codeCondition.test,
resourceQuery: /__sc_client__/,
use: {
loader: 'next-flight-client-loader',
},
Expand Down
Expand Up @@ -94,11 +94,8 @@ export default async function transformSource(
this: any,
source: string
): Promise<string> {
const { resourcePath, resourceQuery } = this
const { resourcePath } = this

if (resourceQuery !== '?flight') return source

let url = resourcePath
const transformedSource = source
if (typeof transformedSource !== 'string') {
throw new Error('Expected source to have been transformed to a string.')
Expand All @@ -108,7 +105,7 @@ export default async function transformSource(
await parseExportNamesInto(resourcePath, transformedSource, names)

// next.js/packages/next/<component>.js
if (/[\\/]next[\\/](link|image)\.js$/.test(url)) {
if (/[\\/]next[\\/](link|image)\.js$/.test(resourcePath)) {
names.push('default')
}

Expand All @@ -122,7 +119,7 @@ export default async function transformSource(
newSrc += 'export const ' + name + ' = '
}
newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: '
newSrc += JSON.stringify(url)
newSrc += JSON.stringify(resourcePath)
newSrc += ', name: '
newSrc += JSON.stringify(name)
newSrc += '};\n'
Expand Down
121 changes: 83 additions & 38 deletions packages/next/build/webpack/loaders/next-flight-server-loader.ts
Expand Up @@ -5,17 +5,19 @@ import { parse } from '../../swc'
import { getBaseSWCOptions } from '../../swc/options'
import { getRawPageExtensions } from '../../utils'

function isClientComponent(importSource: string, pageExtensions: string[]) {
return new RegExp(`\\.client(\\.(${pageExtensions.join('|')}))?`).test(
importSource
)
}
const getIsClientComponent =
huozhi marked this conversation as resolved.
Show resolved Hide resolved
(pageExtensions: string[]) => (importSource: string) => {
return new RegExp(`\\.client(\\.(${pageExtensions.join('|')}))?`).test(
importSource
)
}

function isServerComponent(importSource: string, pageExtensions: string[]) {
return new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?`).test(
importSource
)
}
const getIsServerComponent =
(pageExtensions: string[]) => (importSource: string) => {
return new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?`).test(
importSource
)
}

function isNextComponent(importSource: string) {
return (
Expand All @@ -31,21 +33,28 @@ export function isImageImport(importSource: string) {
)
}

async function parseImportsInfo(
resourcePath: string,
source: string,
imports: Array<string>,
isClientCompilation: boolean,
pageExtensions: string[]
): Promise<{
async function parseImportsInfo({
resourcePath,
source,
imports,
isClientCompilation,
isServerComponent,
isClientComponent,
}: {
resourcePath: string
source: string
imports: Array<string>
isClientCompilation: boolean
isServerComponent: (name: string) => boolean
isClientComponent: (name: string) => boolean
}): Promise<{
source: string
defaultExportName: string
}> {
const opts = getBaseSWCOptions({
filename: resourcePath,
globalWindow: isClientCompilation,
})

const ast = await parse(source, { ...opts.jsc.parser, isModule: true })
const { body } = ast
const beginPos = ast.span.start
Expand All @@ -58,29 +67,49 @@ async function parseImportsInfo(
case 'ImportDeclaration': {
const importSource = node.source.value
if (!isClientCompilation) {
// Server compilation for .server.js.
if (isServerComponent(importSource)) {
continue
}

const importDeclarations = source.substring(
lastIndex,
node.source.span.start - beginPos
)

if (
!(
isClientComponent(importSource, pageExtensions) ||
isClientComponent(importSource) ||
huozhi marked this conversation as resolved.
Show resolved Hide resolved
isNextComponent(importSource) ||
isImageImport(importSource)
)
) {
continue
if (
['react/jsx-runtime', 'react/jsx-dev-runtime'].includes(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks tricky, is there any other library having the similar reason that we need to skip?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a comment for it later

Copy link
Member Author

@shuding shuding Feb 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to change that with a webpack alias (react/jsx-runtime?__rsc_server__ -> react/jsx-runtime), but not sure if that's more readable than this.

importSource
)
) {
continue
}

// A shared component. It should be handled as a server
// component.
transformedSource += importDeclarations
transformedSource += JSON.stringify(`${importSource}?__sc_server__`)
} else {
// A client component. It should be loaded as module reference.
transformedSource += importDeclarations
transformedSource += JSON.stringify(`${importSource}?__sc_client__`)
imports.push(`require(${JSON.stringify(importSource)})`)
}
const importDeclarations = source.substring(
lastIndex,
node.source.span.start - beginPos
)
transformedSource += importDeclarations
transformedSource += JSON.stringify(`${node.source.value}?flight`)
} else {
// For the client compilation, we skip all modules imports but
// always keep client components in the bundle. All client components
// have to be imported from either server or client components.
if (
!(
isClientComponent(importSource, pageExtensions) ||
isServerComponent(importSource, pageExtensions) ||
isClientComponent(importSource) ||
isServerComponent(importSource) ||
// Special cases for Next.js APIs that are considered as client
// components:
isNextComponent(importSource) ||
Expand All @@ -89,11 +118,12 @@ async function parseImportsInfo(
) {
continue
}

imports.push(`require(${JSON.stringify(importSource)})`)
}

lastIndex = node.source.span.end - beginPos
imports.push(`require(${JSON.stringify(importSource)})`)
continue
break
}
case 'ExportDefaultDeclaration': {
const def = node.decl
Expand Down Expand Up @@ -126,28 +156,44 @@ export default async function transformSource(
this: any,
source: string
): Promise<string> {
const { client: isClientCompilation, pageExtensions: pageExtensionsJson } =
this.getOptions()
const { resourcePath } = this
const pageExtensions = JSON.parse(pageExtensionsJson)
const { client: isClientCompilation, pageExtensions } = this.getOptions()
const { resourcePath, resourceQuery } = this

if (typeof source !== 'string') {
throw new Error('Expected source to have been transformed to a string.')
}

// We currently assume that all components are shared components (unsuffixed)
// from node_modules.
if (resourcePath.includes('/node_modules/')) {
return source
}

const rawRawPageExtensions = getRawPageExtensions(pageExtensions)
const isServerComponent = getIsServerComponent(rawRawPageExtensions)
const isClientComponent = getIsClientComponent(rawRawPageExtensions)

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__'
) {
return source
}
}

const imports: string[] = []
const { source: transformedSource, defaultExportName } =
await parseImportsInfo(
await parseImportsInfo({
resourcePath,
source,
imports,
isClientCompilation,
getRawPageExtensions(pageExtensions)
)
isServerComponent,
isClientComponent,
})

/**
* For .server.js files, we handle this loader differently.
Expand Down Expand Up @@ -177,6 +223,5 @@ export default async function transformSource(
}

const transformed = transformedSource + '\n' + noop + '\n' + defaultExportNoop

return transformed
}
Expand Up @@ -69,7 +69,7 @@ export class FlightManifestPlugin {
const { clientComponentsRegex } = this
compilation.chunkGroups.forEach((chunkGroup: any) => {
function recordModule(id: string, _chunk: any, mod: any) {
const resource = mod.resource?.replace(/\?flight$/, '')
const resource = mod.resource?.replace(/\?__sc_client__$/, '')

// TODO: Hook into deps instead of the target module.
// That way we know by the type of dep whether to include.
Expand Down
@@ -0,0 +1,7 @@
import { useState } from 'react'

export default function Client() {
// To ensure that this component is rendered as a client component, we use a
// state here.
return useState('client_component')[0]
}
@@ -0,0 +1,3 @@
import Shared from './shared'

export default Shared
@@ -0,0 +1,21 @@
import { useState } from 'react'
import Client from './client.client'

const random = ~~(Math.random() * 10000)

export default function Shared() {
let isServerComponent
try {
useState()
isServerComponent = false
} catch (e) {
isServerComponent = true
}

return (
<>
<Client />,{' '}
{(isServerComponent ? 'shared:server' : 'shared:client') + ':' + random}
</>
)
}
@@ -0,0 +1,22 @@
import ClientFromDirect from '../components/client.client'
import ClientFromShared from '../components/shared'
import SharedFromClient from '../components/shared.client'

export default function Page() {
// All three client components should be rendered correctly, but only
// shared component is a server component, and another is a client component.
// These two shared components should be created as two module instances.
return (
<div id="main">
<ClientFromDirect />
<br />
<ClientFromShared />
<br />
<ClientFromShared />
<br />
<SharedFromClient />
<br />
<SharedFromClient />
</div>
)
}
Expand Up @@ -55,6 +55,26 @@ export default function (context, { runtime, env }) {
expect(html).toContain('foo.client')
})

it('should resolve different kinds of components correctly', async () => {
const html = await renderViaHTTP(context.appPort, '/shared')
const main = getNodeBySelector(html, '#main').html()

// Should have 5 occurrences of "client_component".
expect([...main.matchAll(/client_component/g)].length).toBe(5)

// Should have 2 occurrences of "shared:server", and 2 occurrences of
// "shared:client".
const sharedServerModule = [...main.matchAll(/shared:server:(\d+)/g)]
const sharedClientModule = [...main.matchAll(/shared:client:(\d+)/g)]
expect(sharedServerModule.length).toBe(2)
expect(sharedClientModule.length).toBe(2)

// Should have 2 modules created for the shared component.
expect(sharedServerModule[0][1]).toBe(sharedServerModule[1][1])
expect(sharedClientModule[0][1]).toBe(sharedClientModule[1][1])
expect(sharedServerModule[0][1]).not.toBe(sharedClientModule[0][1])
})

it('should support next/link in server components', async () => {
const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link')
const linkText = getNodeBySelector(
Expand Down
7 changes: 6 additions & 1 deletion yarn.lock
Expand Up @@ -4855,7 +4855,7 @@
version "4.1.5"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd"

"@types/eslint-scope@^3.7.3":
"@types/eslint-scope@^3.7.0", "@types/eslint-scope@^3.7.3":
version "3.7.3"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
integrity sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==
Expand All @@ -4880,6 +4880,11 @@
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"

"@types/estree@^0.0.50":
version "0.0.50"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==

"@types/estree@^0.0.51":
version "0.0.51"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
Expand Down