Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/next/src/build/analysis/get-page-static-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export function getRSCModuleInformation(
isReactServerLayer: boolean
): RSCMeta {
const actionsJson = source.match(ACTION_MODULE_LABEL)
const actions = actionsJson
? (Object.values(JSON.parse(actionsJson[1])) as string[])
const parsedActionsMeta = actionsJson
? (JSON.parse(actionsJson[1]) as Record<string, string>)
: undefined
const actions = parsedActionsMeta
? (Object.values(parsedActionsMeta) as string[])
: undefined
const clientInfoMatch = source.match(CLIENT_MODULE_LABEL)
const isClientRef = !!clientInfoMatch
Expand All @@ -91,6 +94,7 @@ export function getRSCModuleInformation(
return {
type: RSC_MODULE_TYPES.client,
actions,
actionIds: parsedActionsMeta,
isClientRef,
}
}
Expand Down
12 changes: 12 additions & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,7 @@ export default async function getBaseWebpackConfig(
'next-flight-client-entry-loader',
'next-flight-action-entry-loader',
'next-flight-client-module-loader',
'next-flight-server-reference-proxy-loader',
'empty-loader',
'next-middleware-loader',
'next-edge-function-loader',
Expand Down Expand Up @@ -1680,6 +1681,17 @@ export default async function getBaseWebpackConfig(
test: /[\\/]next[\\/]dist[\\/](esm[\\/])?server[\\/]og[\\/]image-response\.js/,
sideEffects: false,
},
// Mark the action-client-wrapper module as side-effects free to make sure
// the individual transformed module of client action can be tree-shaken.
// This will make modules processed by `next-flight-server-reference-proxy-loader` become side-effects free,
// then on client side the module ids will become tree-shakable.
// e.g. the output of client action module will look like:
// `export { a } from 'next-flight-server-reference-proxy-loader?id=idOfA&name=a!
// `export { b } from 'next-flight-server-reference-proxy-loader?id=idOfB&name=b!
{
test: /[\\/]next[\\/]dist[\\/](esm[\\/])?build[\\/]webpack[\\/]loaders[\\/]next-flight-loader[\\/]action-client-wrapper\.js/,
sideEffects: false,
},
{
// This loader rule should be before other rules, as it can output code
// that still contains `"use client"` or `"use server"` statements that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function getModuleBuildInfo(webpackModule: webpack.Module) {
export interface RSCMeta {
type: RSCModuleType
actions?: string[]
actionIds?: Record<string, string>
clientRefs?: string[]
clientEntryType?: 'cjs' | 'auto'
isClientRef?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,16 @@ const flightClientModuleLoader: webpack.LoaderDefinitionFunction =
const buildInfo = getModuleBuildInfo(this._module)
buildInfo.rsc = getRSCModuleInformation(source, false)

// This is a server action entry module in the client layer. We need to attach
// noop exports of `callServer` wrappers for each action.
if (buildInfo.rsc.actions) {
return `
import { callServer } from 'next/dist/client/app-call-server'

function __build_action__(action, args) {
return callServer(action.$$id, args)
}

${source}
`
// This is a server action entry module in the client layer. We need to create
// re-exports of "virtual modules" to expose the reference IDs to the client
// separately so they won't be always in the same one module which is not
// splittable.
if (buildInfo.rsc.actionIds) {
return Object.entries(buildInfo.rsc.actionIds)
.map(([id, name]) => {
return `export { ${name} } from 'next-flight-server-reference-proxy-loader?id=${id}&name=${name}!'`
})
.join('\n')
}

return this.callback(null, source, sourceMap)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { webpack } from 'next/dist/compiled/webpack/webpack'

// This is a virtual proxy loader that takes a Server Reference ID and a name,
// creates a module that just re-exports the reference as that name.

const flightServerReferenceProxyLoader: webpack.LoaderDefinitionFunction<{
id: string
name: string
}> = function transformSource(this) {
const { id, name } = this.getOptions()

// Both the import and the `createServerReference` call are marked as side
// effect free:
// - private-next-rsc-action-client-wrapper is matched as `sideEffects: false` in
// the Webpack loader
// - createServerReference is marked as /*#__PURE__*/
//
// Because of that, Webpack is able to concatenate the modules and inline the
// reference IDs recursively directly into the module that uses them.
return `\
import { createServerReference } from 'private-next-rsc-action-client-wrapper'
export ${
name === 'default' ? 'default' : `const ${name} =`
} /*#__PURE__*/createServerReference(${JSON.stringify(id)})`
}

export default flightServerReferenceProxyLoader
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use server'

export async function foo() {
console.log('This is action foo')
}

export async function bar() {
console.log('This is action bar')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { foo } from '../actions'

export default function Page() {
return (
<form action={foo}>
<button type="submit" id="submit">
Submit
</button>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { bar } from '../actions'

export default function Page() {
return (
<form action={bar}>
<button type="submit" id="submit">
Submit
</button>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { promises as fs } from 'fs'
import { join } from 'path'
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'

function getServerReferenceIdsFromBundle(source: string): string[] {
// Reference IDs are strings with [0-9a-f] that are at least 32 characters long.
// We use RegExp to find them in the bundle.
const referenceIds = source.matchAll(/"([0-9a-f]{32,})"/g) || []
return [...referenceIds].map(([, id]) => id)
}

describe('app-dir - client-actions-tree-shaking', () => {
const { next } = nextTestSetup({
files: __dirname,
})

const logs: string[] = []

beforeAll(() => {
const onLog = (log: string) => {
logs.push(log.trim())
}
next.on('stdout', onLog)
next.on('stderr', onLog)
})

afterEach(async () => {
logs.length = 0
})

it('should not bundle unused server reference id in client bundles', async () => {
const appDir = next.testDir
const route1Files = await fs.readdir(
join(appDir, '.next/static/chunks/app/route-1')
)
const route2Files = await fs.readdir(
join(appDir, '.next/static/chunks/app/route-2')
)

const route1Bundle = await fs.readFile(
join(
appDir,
'.next/static/chunks/app/route-1',
route1Files.find((file) => file.endsWith('.js'))
)
)
const route2Bundle = await fs.readFile(
join(
appDir,
'.next/static/chunks/app/route-2',
route2Files.find((file) => file.endsWith('.js'))
)
)

const bundle1Ids = getServerReferenceIdsFromBundle(route1Bundle.toString())
const bundle2Ids = getServerReferenceIdsFromBundle(route2Bundle.toString())

// Each should only have one ID.
expect(bundle1Ids).toHaveLength(1)
expect(bundle2Ids).toHaveLength(1)
expect(bundle1Ids[0]).not.toEqual(bundle2Ids[0])
})

// Test the application
it('should trigger actions correctly', async () => {
const browser = await next.browser('/route-1')
await browser.elementById('submit').click()

await retry(() => {
expect(logs).toEqual(
expect.arrayContaining([expect.stringContaining('This is action foo')])
)
})

const browser2 = await next.browser('/route-2')
await browser2.elementById('submit').click()

await retry(() => {
expect(logs).toEqual(
expect.arrayContaining([expect.stringContaining('This is action bar')])
)
})
})
})
9 changes: 9 additions & 0 deletions test/turbopack-build-tests-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15687,6 +15687,15 @@
"flakey": [],
"runtimeError": false
},
"test/production/app-dir/actions-tree-shaking/client-actions-tree-shaking/client-actions-tree-shaking.test.ts": {
"passed": [],
"failed": [
"app-dir - client-actions-tree-shaking should not bundle unused server reference id in client bundles"
],
"pending": [],
"flakey": [],
"runtimeError": false
},
"test/production/app-dir/app-edge-middleware/app-edge-middleware.test.ts": {
"passed": [
"app edge middleware without node.js modules should not have any errors about using Node.js modules if not present in middleware"
Expand Down