Files
next.js/test/production/standalone-mode/server-action-externals/standalone-mode-server-action-externals.test.ts
danandGitHub b7ab553862 Turbopack: trace externals imported only by server actions (#95824)
#95815 refactored the NFT flow and ended up only tracing the RSC
template and it's subgraph. Not the server actions loader module whose
module graph is completely separate.

---

Clauded.

Attempts to fix
https://github.com/vercel/next.js/discussions/95130#discussioncomment-17652155.

---

<details>

## Summary

Fixes the NFT tracing regression behind #95815 and the deploy failures
reported in
https://github.com/vercel/next.js/issues/87737#issuecomment-4897366831.

Since the 16.3 NFT rewrite (#94224 / #92901), a `serverExternalPackages`
entry that is imported **only from a `'use server'` action referenced by
a client component** ends up in the route's trace as the content-hashed
alias symlink (`.next/node_modules/<pkg>-<hash>`) **without the store
files the symlink points to**. Any output assembled from the traces
(Vercel lambdas, `output: 'standalone'`) then contains a dangling
symlink, and invoking the action throws `Failed to load external module
<pkg>-<hash>` (HTTP 500). 16.2.9 traces both. Both CJS and ESM externals
are affected, with any package manager — the ESM/bun/monorepo framing in
the issue reports was a coincidence of which packages were only
reachable through actions.

The cause: `AppEndpoint::trace_result` started the trace DFS only from
`rsc_entry`, but the server actions loader is a separate module-graph
entry (`additional_entries`). It is chunked into the endpoint output
(which emits the alias symlink via the chunk's output assets), yet its
subgraph — including the externals' traced target files — was never
visited by the tracer. `trace_endpoint` now accepts multiple entry
modules and the app endpoint passes the actions loader alongside
`rsc_entry`.

The test observes the user-visible failure: it builds with `output:
'standalone'`, deletes everything except the standalone output (so only
traced files are available, like a deployed lambda), runs `server.js`,
and invokes the server action in a browser. Without the fix the action
fails with `Failed to load external module lodash-<hash>`; on 16.2.9 the
same fixture responds with the action result. Note the fixture must not
import the externals from any other route: the standalone output is the
union of all route traces, so a route that traces them correctly would
mask the missing entries.

## Verification

- `pnpm test-start-turbo
test/production/standalone-mode/server-action-externals/standalone-mode-server-action-externals.test.ts`
— fails without the fix (`Failed to load external module
lodash-fb78ad0536ffcdab` from the standalone server), passes with it.
The equivalent standalone flow on `[email protected]` returns the action
result with HTTP 200.
- `pnpm test-start-turbo
test/e2e/externals-transitive/externals-transitive.test.ts`
- `pnpm test-start-turbo
test/production/standalone-mode/server-actions/standalone-mode-server-actions.test.ts`

<!-- NEXT_JS_LLM -->

</details>
2026-07-16 07:13:26 +02:00

65 lines
1.8 KiB
TypeScript

import { ChildProcess } from 'child_process'
import { nextTestSetup } from 'e2e-utils'
import fs from 'fs-extra'
import { findPort, initNextServerScript, killApp, retry } from 'next-test-utils'
import { join } from 'path'
// The external packages are only imported by a server action, so the
// standalone output only contains them if the route's trace includes them.
describe('standalone mode: server action externals', () => {
let server: ChildProcess
let appPort: number
const { next } = nextTestSetup({
files: __dirname,
dependencies: require('./package.json').dependencies,
skipStart: true,
})
beforeAll(async () => {
await next.build()
await fs.move(
join(next.testDir, '.next/standalone'),
join(next.testDir, 'standalone')
)
await fs.copy(
join(next.testDir, '.next/static'),
join(next.testDir, 'standalone/.next/static')
)
// Remove everything else (including node_modules) so that the server can
// only load what the file traces brought into the standalone output.
for (const file of await fs.readdir(next.testDir)) {
if (file !== 'standalone') {
await fs.remove(join(next.testDir, file))
}
}
const testServer = join(next.testDir, 'standalone/server.js')
appPort = await findPort()
server = await initNextServerScript(testServer, /- Local:/, {
...process.env,
...next.env,
HOSTNAME: '::',
PORT: '' + appPort,
})
})
afterAll(async () => {
if (server) {
await killApp(server)
}
})
it('should execute a server action that uses external packages', async () => {
const browser = await next.browser('/', { baseUrl: appPort })
await browser.elementByCss('button').click()
await retry(async () => {
expect(await browser.elementByCss('#result').text()).toBe('helloWorld')
})
})
})