From e8fcd8cc2411421c3e5de58dc1fb8c725388f8be Mon Sep 17 00:00:00 2001 From: Mark Russell Date: Sat, 14 Nov 2026 19:48:00 -0500 Subject: [PATCH 1/3] fix(babel): do not change "use server" and "use cache" code Next.js accepts only native async functions in a "use server" or "use cache" file or function, without this and arguments. The preset changed them, and Next.js stopped the build (issue #69). Each plugin of the preset after the first does nothing on server code: a program or a function with "use server", "use cache" or "use cache: ". The guard does not skip the shared traversal, so the other presets of the configuration still change that code. The own traversals of the plugins skip the server functions. The Next.js fixture of the build tool test now starts a server action and a "use cache" function, and a new case builds the Turbopack rule of the guide. The guide makes that rule the main Next.js setup, and tells the pnpm settings, the alias and the costs. 0.3.2, PATCH: a bug fix before 1.0.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + README.md | 3 +- package.json | 2 +- site/content/docs/api.mdx | 2 +- site/content/docs/concepts/transform.mdx | 2 + site/content/docs/guides/build-tools.mdx | 109 ++++++++- src/babel/async-generator-resources.ts | 6 +- src/babel/bind-generators.ts | 7 +- src/babel/name-coroutines.ts | 7 +- src/babel/preset.ts | 35 ++- src/babel/server-code.test.ts | 217 ++++++++++++++++++ src/babel/server-code.ts | 121 ++++++++++ src/babel/skip-runtime.ts | 22 +- src/babel/static-members.ts | 16 +- .../next-turbopack-rule/next.config.mjs | 38 +++ test/bundlers/fixture/next/app/actions.js | 9 + test/bundlers/fixture/next/app/cached.js | 11 + test/bundlers/fixture/next/app/page.js | 8 +- test/bundlers/fixture/next/next.config.mjs | 10 +- test/bundlers/run.ts | 32 ++- 20 files changed, 620 insertions(+), 44 deletions(-) create mode 100644 src/babel/server-code.test.ts create mode 100644 src/babel/server-code.ts create mode 100644 test/bundlers/fixture/next-turbopack-rule/next.config.mjs create mode 100644 test/bundlers/fixture/next/app/actions.js create mode 100644 test/bundlers/fixture/next/app/cached.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ec72a27..e037bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change history +## 0.3.2 + +This version fixes a bug, so it is a patch version. + +- The Babel preset does not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. Next.js accepts only native async functions there. Before, the preset changed them into functions that are not async and that use `this` and `arguments`. Next.js then stopped the build, also when a Turbopack rule gave only the files of the browser to the preset. The other presets and plugins of the configuration still change this code. The Vite plugin uses the preset, so it gets the same fix. +- The guide "Build tools" recommends a Turbopack rule with `babel-loader` for Next.js. It also tells the pnpm settings with which a dependency finds the runtime, and how to make Babel do less work. CI builds the rule with a server action and a `"use cache"` function. + ## 0.3.1 This version fixes a bug, so it is a patch version. diff --git a/README.md b/README.md index af3a212..6ec6467 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ A test in CI (`pnpm test:bundlers`) builds a page with each of these setups and | webpack 5, Rspack 2 | `babel-loader` with the preset. Do not exclude `node_modules`. | All | | Rollup 4 | `@rollup/plugin-babel` with the preset | All | | esbuild | A small plugin that applies the preset | All | -| Next.js 16, Turbopack or webpack | `babel.config.js` with `next/babel` and the preset, and `transpilePackages` for the dependencies | All | +| Next.js 16, Turbopack | A `turbopack.rules` rule: `babel-loader` with only the preset, for the files of the browser | All | +| Next.js 16, webpack | `babel.config.js` with `next/babel` and the preset, and `transpilePackages` for the dependencies | All | | SWC, esbuild, Next.js without Babel | The target ES2016: the compiler changes async functions into generators | All except rule C5 | With the target ES2016, the patch of `then` keeps the context through `await`. A generator body then gets the context of each `next()` call, and the callback hooks get no names. diff --git a/package.json b/package.json index 3e5a43c..c48d759 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "async-browser-context", - "version": "0.3.1", + "version": "0.3.2", "description": "AsyncLocalStorage and the TC39 AsyncContext API (Variable, Snapshot) for browsers. The context stays through await, promises, timers, events and generators. A Babel preset and a Vite plugin transform async functions. On Node.js, the package uses the native AsyncLocalStorage.", "license": "MIT", "author": "Mark Russell", diff --git a/site/content/docs/api.mdx b/site/content/docs/api.mdx index f4672e9..fa49eca 100644 --- a/site/content/docs/api.mdx +++ b/site/content/docs/api.mdx @@ -191,7 +191,7 @@ The plugin applies the Babel preset to each JavaScript and TypeScript module, an | `names` | `true` | Give the name of each async function to the callback hooks. The option of the preset tells more. | | `ssr` | `false` | Also transform the modules of server-side rendering. On Node.js, the native `AsyncLocalStorage` keeps the context, so the default is `false`. Set it to `true` for tests in the Node.js environment of Vitest. | -The plugin does not change a module that has no async function, no generator and no `for await` loop. It does not transform the runtime of the library. The plugin skips the files in the `dist` folder of the package, and the preset skips the functions with the directive of the runtime. +The plugin does not change a module that has no async function, no generator and no `for await` loop. It does not transform the runtime of the library. The plugin skips the files in the `dist` folder of the package, and the preset skips the functions with the directive of the runtime. The preset also does not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. The plugin gives Babel the source type "module", because Vite serves each module as an ES module, also a worker. Only a `.cjs` or `.cts` file, or a file with CommonJS marks and no static `import` or `export`, can be a script. The plugin does not read an input source map, because the map of Vite describes the code that the plugin gets. It keeps the format of large modules (`compact: false`). diff --git a/site/content/docs/concepts/transform.mdx b/site/content/docs/concepts/transform.mdx index cbb2cd0..924171d 100644 --- a/site/content/docs/concepts/transform.mdx +++ b/site/content/docs/concepts/transform.mdx @@ -22,6 +22,8 @@ The preset `async-browser-context/babel-preset` contains nine Babel plugins, in 8. `@babel/plugin-transform-async-generator-functions` changes the async generators and the `for await` loops. The preset makes its own traversal also skip the functions with the directive of the runtime. 9. `@babel/plugin-transform-async-to-generator` changes each async function into a generator function. Its `module` and `method` options make the code use `coroutine` from `async-browser-context/runtime`. +Steps 2 to 9 do not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. Next.js accepts only native async functions there, and the server keeps the context with the native `AsyncLocalStorage`. These plugins do not skip the code in the shared traversal of Babel. Thus the other presets, for example `@babel/preset-typescript`, still change it. The [Next.js](/docs/guides/build-tools#server-actions-and-use-cache) section of the build tools guide tells more. + The Babel transforms of steps 8 and 9 already handle destructuring, `var`, labels, closures, sync iterables and the `return()` use after `break`. The library adds no custom code for `await`. The plugin of step 5 sets the helpers in the `Program` visitor. Thus the preset also operates with `@babel/plugin-transform-runtime`, which gives the other helpers of Babel from `@babel/runtime`. diff --git a/site/content/docs/guides/build-tools.mdx b/site/content/docs/guides/build-tools.mdx index 92cda64..d1c1fd4 100644 --- a/site/content/docs/guides/build-tools.mdx +++ b/site/content/docs/guides/build-tools.mdx @@ -15,7 +15,7 @@ Use the Babel preset if your build tool can use Babel. Use the target ES2016 if ## The tested setups -The command `pnpm test:bundlers` builds the same page with each setup, and opens the page in Chromium. The page examines the context after `await`, in a callback of a dependency in `node_modules`, in a generator and in an event of the browser. CI starts this test for each change. +The command `pnpm test:bundlers` builds the same page with each setup, and opens the page in Chromium. The page examines the context after `await`, in a callback of a dependency in `node_modules`, in a generator and in an event of the browser. The Next.js page also starts a server action and a `"use cache"` function. CI starts this test for each change. | Tool | Setup | Rules | | --- | --- | --- | @@ -26,11 +26,12 @@ The command `pnpm test:bundlers` builds the same page with each setup, and opens | Rollup 4 | `@rollup/plugin-babel` with the preset | All rules | | esbuild 0.28 | A plugin that applies the preset | All rules | | esbuild 0.28 | The target `es2016` | All rules except rule C5 | +| Next.js 16, Turbopack | A rule with `babel-loader` and the preset | All rules | | Next.js 16, Turbopack | A Babel configuration file | All rules | | Next.js 16, webpack | A Babel configuration file | All rules | | Next.js 16, Turbopack | A `browserslist` query with a browser without async functions | All rules except rule C5 | -The configurations of the test are in `test/bundlers/fixture/build.mjs` and `test/bundlers/fixture/next/`. +The configurations of the test are in `test/bundlers/fixture/build.mjs`, `test/bundlers/fixture/next/` and `test/bundlers/fixture/next-turbopack-rule/`. ## Vite @@ -146,6 +147,98 @@ Without the plugin, set the target `es2016`. esbuild then changes each async fun ## Next.js +CI builds and examines three setups with Next.js 16. The page of the test also starts a server action, which starts a `"use cache"` function. + +| Setup | Rules | The code that changes | Use it | +| --- | --- | --- | --- | +| [A Turbopack rule](#a-turbopack-rule) | All rules | The files of the browser with async functions or generators, also in `node_modules` | With Turbopack. This is the recommended setup. | +| [A Babel configuration file](#a-babel-configuration-file) | All rules | All your code, on the server and in the browser, and the packages in `transpilePackages` | With webpack (`next build --webpack`) | +| [SWC with a `browserslist` query](#swc-with-a-browserslist-query) | All rules except rule C5 | All the code of the browser, also in `node_modules` | If you do not want Babel | + +### A Turbopack rule + +Install `babel-loader`. Then add a rule for each kind of file to `next.config.mjs`: + +```js +// next.config.mjs +/** The options of babel-loader for one kind of file. */ +function babel(syntax) { + return { + loader : "babel-loader", + options : { + babelrc : false, + configFile : false, + sourceType : "unambiguous", + parserOpts : { plugins : syntax }, + presets : ["async-browser-context/babel-preset"], + }, + }; +} + +/** The files of the browser with async functions or generators. */ +const condition = { all : ["browser", { content : /\basync\b|\byield\b|function\s*\*/ }] }; + +export default { + turbopack : { + rules : { + "*.{js,jsx,mjs,cjs}" : { condition, loaders : [babel(["jsx"])] }, + "*.{ts,mts,cts}" : { condition, loaders : [babel(["typescript"])] }, + "*.tsx" : { condition, loaders : [babel(["typescript", "jsx"])] }, + }, + }, +}; +``` + +- The loader applies only the preset of the library. Babel reads the TypeScript and JSX syntax (`parserOpts`), but it does not change it. After the loader, SWC compiles the file as usual. +- A `.ts` file cannot have JSX. Thus TypeScript files and TSX files have different rules. +- Turbopack gives the options of a loader as JSON. Thus use only names, strings, numbers and lists in `options`. Do not use functions or regular expressions there. +- The `browser` condition selects the code of the browser. On the server, the `node` export condition selects the Node.js entry of the library, which uses the native `AsyncLocalStorage`. The server code thus does not need the transform. +- The rule also applies to the packages in `node_modules`. To exclude a file or a package, add a condition to the `all` list, for example `{ not : { path : /node_modules\/some-package\// } }`. `{ not : "foreign" }` excludes all of `node_modules`. A callback that a package starts after its own `await` then gets the root context (refer to [the dependencies](#the-dependencies)). + +The configuration of the test is in `test/bundlers/fixture/next-turbopack-rule/next.config.mjs`. + +### Server Actions and `"use cache"` + +The preset does not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. Next.js accepts only native async functions there. Thus you do not exclude these files from a rule. In the bundle of the browser, Next.js replaces a server action with a reference to the server. The server keeps the context with the native `AsyncLocalStorage`. + +The preset changes the other functions of the file. For example, an async page function can have a server action in it. The page function changes, and the server action stays a native async function. + +The preset of version 0.3.1 and earlier changed these functions, and Next.js then stopped the build. + +### pnpm + +The preset adds an import of `async-browser-context/runtime` to each file that it changes, also to the files of dependencies. Thus each dependency that the rule changes must find the library from its own folder. With pnpm, this depends on the settings: + +| pnpm setting | A dependency finds the runtime | What to do | +| --- | --- | --- | +| The default `hoistPattern` (`*`) | Yes, in `node_modules/.pnpm/node_modules` | Nothing | +| `hoist: false`, or a `hoistPattern` without `async-browser-context` | No | Add `async-browser-context` to `publicHoistPattern`, or add the alias below | +| `enableGlobalVirtualStore: true` | No | Add the alias below | + +The alias sends each import of the runtime in the browser to one file. The path goes from the folder of `next.config.mjs` to the `node_modules` folder with the library. In a monorepo, this can be `../../node_modules`. + +```js +// next.config.mjs +export default { + turbopack : { + resolveAlias : { + "async-browser-context/runtime" : { browser : "./node_modules/async-browser-context/dist/runtime.js" }, + }, + rules : { /* the rules above */ }, + }, +}; +``` + +### Performance + +Babel is slower than SWC. The rule above gives Babel only the files of the browser with the words `async`, `yield` or `function*` (the `content` condition). SWC compiles all the other files without Babel. The Vite plugin of the library uses the same filter. + +A file that Babel changes gets generators in place of async functions, and it uses the runtime. Thus the bundle gets larger. The cost depends on the number of files with async functions in your code and in your dependencies. Compare the time of `next build` and the size of the bundle with and without the rule. To make Babel do less work, exclude packages with a `path` condition. Exclude only the packages that do not start your code after an `await`. + +The build tool test writes the time of each Next.js build to its output. These times come from one build of a small page, so they are not a benchmark. + +### A Babel configuration file + 1. Add a Babel configuration file next to `package.json`, with the preset of Next.js and the preset of the library: ```js @@ -162,11 +255,15 @@ Without the plugin, set the target `es2016`. esbuild then changes each async fun export default { transpilePackages : ["some-dependency"] }; ``` -With a Babel configuration file, Next.js uses Babel in place of SWC for your code, with Turbopack and with webpack. The build takes more time. Next.js does not apply the Babel configuration to the other packages in `node_modules`. +With a Babel configuration file, Next.js uses Babel in place of SWC for all your code, with Turbopack and with webpack. The build takes more time. Next.js does not apply the Babel configuration to the other packages in `node_modules`. With Turbopack, use [the rule](#a-turbopack-rule): SWC then compiles all the other files. + +The server code also gets the transform. On the server, the `node` export condition selects the Node.js entry of the library, which uses the native `AsyncLocalStorage`. The preset does not change server actions and `"use cache"` functions (refer to [Server Actions](#server-actions-and-use-cache)). + +### SWC with a `browserslist` query -The server code also gets the transform. On the server, the `node` export condition selects the Node.js entry of the library, which uses the native `AsyncLocalStorage`. +Next.js can also keep SWC for all files. Add a browser without async functions to the `browserslist` query of `package.json`, for example `"chrome 54"`. SWC then changes each async function into a generator. Turbopack applies the query also to `node_modules`. The webpack mode of Next.js does not, so it needs `transpilePackages`. -Next.js can also keep SWC. Add a browser without async functions to the `browserslist` query of `package.json`, for example `"chrome 54"`. SWC then changes each async function into a generator. Turbopack applies the query also to `node_modules`. The webpack mode of Next.js does not, so it needs `transpilePackages`. The query also changes all newer syntax, so the bundles get larger. +This setup keeps all rules except rule C5, and the callback hooks get no names (refer to [what the target ES2016 does not keep](#what-the-target-es2016-does-not-keep)). The query also changes all newer syntax, so the bundles get larger. The library has no SWC plugin. ## SWC @@ -192,7 +289,7 @@ The setups on this page minify after the transform of each module, so they are c ## The dependencies -A dependency with native async functions continues after its own `await` without the context. A callback that the dependency starts after that `await` gets the root context (rule C7). It does not get the context of a different operation. Each setup on this page also transforms `node_modules`, except Next.js, which needs `transpilePackages`. +A dependency with native async functions continues after its own `await` without the context. A callback that the dependency starts after that `await` gets the root context (rule C7). It does not get the context of a different operation. Each setup on this page also transforms `node_modules`, except the Babel configuration file of Next.js, which needs `transpilePackages`. A dependency that its authors compiled to generators before the publish needs nothing. Examples are the `__awaiter` helper of TypeScript and `_asyncToGenerator` of Babel. The helpers continue in `then` callbacks, and the patch of `then` keeps the context. diff --git a/src/babel/async-generator-resources.ts b/src/babel/async-generator-resources.ts index e5948e6..e552e50 100644 --- a/src/babel/async-generator-resources.ts +++ b/src/babel/async-generator-resources.ts @@ -1,5 +1,5 @@ import type { NodePath, PluginObject, PluginPass, types } from "@babel/core"; -import { isRuntimeFunction } from "./runtime-calls.js"; +import { isUnchangedFunction } from "./server-code.js"; import type { PluginFactory } from "./skip-runtime.js"; /** @@ -11,7 +11,7 @@ import type { PluginFactory } from "./skip-runtime.js"; * This function gives a plugin that changes the `using` declarations of each async generator * in `Program` enter, with the visitor of `resources`. The preset puts it after the plugin of * `using` declarations and before the plugin of async generators. The functions with the - * directive of the runtime stay as they are. + * directive of the runtime and the server functions (server-code.ts) stay as they are. */ export function asyncGeneratorResources(resources : PluginFactory) : PluginFactory { return function asyncGeneratorResourcesPlugin(api, options, dirname) : PluginObject { @@ -23,7 +23,7 @@ export function asyncGeneratorResources(resources : PluginFactory) : PluginFacto Program(programPath, state) { programPath.traverse({ Function(functionPath : NodePath) { - if (isRuntimeFunction(functionPath.node)) { + if (isUnchangedFunction(functionPath.node)) { functionPath.skip(); return; } diff --git a/src/babel/bind-generators.ts b/src/babel/bind-generators.ts index f249cc3..d309a09 100644 --- a/src/babel/bind-generators.ts +++ b/src/babel/bind-generators.ts @@ -1,6 +1,7 @@ import { types, type NodePath, type PluginObject, type PluginPass } from "@babel/core"; import { addNamed } from "@babel/helper-module-imports"; -import { isRuntimeCallee, isRuntimeFunction } from "./runtime-calls.js"; +import { isRuntimeCallee } from "./runtime-calls.js"; +import { isUnchangedFunction } from "./server-code.js"; /** The options of the plugin. */ export interface BindGeneratorsOptions { @@ -151,8 +152,8 @@ export function bindGeneratorsPlugin(_api : unknown, options : object) : PluginO programPath.traverse({ Function(path) { const node = path.node; - if (isRuntimeFunction(node)) { - // The native async functions of the runtime stay native (skip-runtime.ts) + if (isUnchangedFunction(node)) { + // The native async functions of the runtime and the server functions stay native (skip-runtime.ts, server-code.ts) path.skip(); return; } diff --git a/src/babel/name-coroutines.ts b/src/babel/name-coroutines.ts index 37ad63c..5f66afe 100644 --- a/src/babel/name-coroutines.ts +++ b/src/babel/name-coroutines.ts @@ -1,5 +1,6 @@ import { types, type NodePath, type PluginObject, type PluginPass } from "@babel/core"; -import { isRuntimeCallee, isRuntimeFunction } from "./runtime-calls.js"; +import { isRuntimeCallee } from "./runtime-calls.js"; +import { isUnchangedFunction } from "./server-code.js"; /** The options of the plugin. */ export interface NameCoroutinesOptions { @@ -213,8 +214,8 @@ export function nameCoroutinesPlugin(_api : unknown, options : object) : PluginO namesOfFile.set(state, names); programPath.traverse({ Function(path) { - if (isRuntimeFunction(path.node)) { - // The native async functions of the runtime stay native (skip-runtime.ts) + if (isUnchangedFunction(path.node)) { + // The native async functions of the runtime and the server functions stay native (skip-runtime.ts, server-code.ts) path.skip(); return; } diff --git a/src/babel/preset.ts b/src/babel/preset.ts index f93f8ad..be002ea 100644 --- a/src/babel/preset.ts +++ b/src/babel/preset.ts @@ -6,6 +6,7 @@ import { asyncGeneratorHelpersPlugin } from "./async-generator-helpers.js"; import { asyncGeneratorResources } from "./async-generator-resources.js"; import { bindGeneratorsPlugin } from "./bind-generators.js"; import { nameCoroutinesPlugin } from "./name-coroutines.js"; +import { withServerCodeSkipped } from "./server-code.js"; import { skipRuntimePlugin, withRuntimeSkipped, type PluginFactory } from "./skip-runtime.js"; import { staticMembersPlugin } from "./static-members.js"; @@ -28,11 +29,23 @@ export interface PresetOptions { readonly names? : boolean | undefined; } +/* + * Each plugin after the first does not change server code (server-code.ts). The preset makes these + * plugins one time, so that the cache of Babel finds the same function for each file. + */ + /** The plugin of Babel for async generators, without a change of the functions of the runtime (skip-runtime.ts). */ -const asyncGeneratorFunctionsOutsideRuntime = withRuntimeSkipped(asyncGeneratorFunctions as unknown as PluginFactory); +const asyncGeneratorFunctionsOutsideRuntime = withServerCodeSkipped(withRuntimeSkipped(asyncGeneratorFunctions as unknown as PluginFactory)); /** The plugin of `using` declarations for async generators, before the plugin of Babel for async generators (async-generator-resources.ts). */ -const asyncGeneratorResourcesPlugin = asyncGeneratorResources(explicitResourceManagement as unknown as PluginFactory); +const asyncGeneratorResourcesPlugin = withServerCodeSkipped(asyncGeneratorResources(explicitResourceManagement as unknown as PluginFactory)); + +const explicitResourceManagementPlugin = withServerCodeSkipped(explicitResourceManagement as unknown as PluginFactory); +const asyncToGeneratorPlugin = withServerCodeSkipped(asyncToGenerator as unknown as PluginFactory); +const bindGenerators = withServerCodeSkipped(bindGeneratorsPlugin as PluginFactory); +const nameCoroutines = withServerCodeSkipped(nameCoroutinesPlugin as PluginFactory); +const staticMembers = withServerCodeSkipped(staticMembersPlugin as PluginFactory); +const asyncGeneratorHelpers = withServerCodeSkipped(asyncGeneratorHelpersPlugin as PluginFactory); /** The default runtime module. */ export const DEFAULT_RUNTIME = "async-browser-context/runtime"; @@ -64,6 +77,12 @@ export const DEFAULT_RUNTIME = "async-browser-context/runtime"; * 9. `@babel/plugin-transform-async-to-generator` changes each async function * into a generator that the `coroutine` function of the runtime operates. * + * The steps 2 to 9 do not change server code of React Server Components: a file or a + * function with the directive `"use server"`, `"use cache"` or + * `"use cache: "` (server-code.ts). Next.js accepts only native async + * functions there. The other presets and plugins of the configuration still + * change that code. + * * The preset operates with Babel 7.22 and later and with Babel 8. Put it last * in the `presets` list: Babel uses the presets in reverse order. */ @@ -71,19 +90,19 @@ export default function asyncBrowserContextPreset(api : PresetAPI, options : Pre api.assertVersion("^7.22.0 || ^8.0.0"); const runtime = options.runtime ?? DEFAULT_RUNTIME; // Stryker disable next-line ObjectLiteral: the plugin also finds the local names that the preset gives, for example _coroutine, without the runtime - const names : PluginItem[] = options.names === false ? [] : [[nameCoroutinesPlugin, { runtime }]]; + const names : PluginItem[] = options.names === false ? [] : [[nameCoroutines, { runtime }]]; // The async transform keeps an `await using` declaration, which is a SyntaxError in a generator - const resources : PluginItem[] = supportsResourceManagement(api.version) ? [explicitResourceManagement, asyncGeneratorResourcesPlugin] : []; + const resources : PluginItem[] = supportsResourceManagement(api.version) ? [explicitResourceManagementPlugin, asyncGeneratorResourcesPlugin] : []; return { plugins : [ skipRuntimePlugin, - [bindGeneratorsPlugin, { runtime }], + [bindGenerators, { runtime }], ...names, - staticMembersPlugin, - [asyncGeneratorHelpersPlugin, { runtime }], + staticMembers, + [asyncGeneratorHelpers, { runtime }], ...resources, asyncGeneratorFunctionsOutsideRuntime, - [asyncToGenerator, { module : runtime, method : "coroutine" }], + [asyncToGeneratorPlugin, { module : runtime, method : "coroutine" }], ], }; } diff --git a/src/babel/server-code.test.ts b/src/babel/server-code.test.ts new file mode 100644 index 0000000..652e410 --- /dev/null +++ b/src/babel/server-code.test.ts @@ -0,0 +1,217 @@ +import { parseSync, transformAsync, transformFromAstAsync, type PluginObject, type PluginPass } from "@babel/core"; +import { describe, expect, it } from "vitest"; +import preset, { METADATA_KEY } from "./preset.js"; +import { isServerDirective, withServerCodeSkipped } from "./server-code.js"; +import type { PluginFactory } from "./skip-runtime.js"; + +interface Output { + readonly code : string; + readonly changed : boolean; +} + +async function transform(code : string, plugins : PluginObject[] = []) : Promise { + const result = await transformAsync(code, { + filename : "input.js", + babelrc : false, + configFile : false, + sourceType : "module", + plugins : plugins.map((plugin) => () => plugin), + presets : [preset], + }); + return { + code : result?.code ?? "", + changed : (result?.metadata as Record | undefined)?.[METADATA_KEY] === true, + }; +} + +/** The text of the runtime in the output: an import or a call of a function of the runtime. */ +const RUNTIME = /async-browser-context|_coroutine|_bindGenerator|_asyncGenerator|_awaitAsyncGenerator|_asyncGeneratorDelegate/; + +describe("server directives", () => { + it("knows the directives of Next.js: use server, use cache and use cache: ", () => { + expect(isServerDirective("use server")).toBe(true); + expect(isServerDirective("use cache")).toBe(true); + expect(isServerDirective("use cache: remote")).toBe(true); + expect(isServerDirective("use cache: private")).toBe(true); + expect(isServerDirective("use cache:remote")).toBe(false); + expect(isServerDirective("use client")).toBe(false); + expect(isServerDirective("use strict")).toBe(false); + expect(isServerDirective("use servers")).toBe(false); + }); + + for (const directive of ["\"use server\"", "'use server'", "\"use cache\"", "\"use cache: remote\""]) { + it(`does not change a file with the directive ${directive}`, async () => { + const output = await transform(`${directive}; +export async function save(form) { await db.write(form); return this ?? arguments.length; } +export const remove = async (id) => { await db.remove(id); }; +export default async function () { await null; } +function* ids() { yield 1; } +async function* rows() { for await (const row of source()) yield row; } +class Store { static load = async () => { await null; return this; }; }`); + expect(output.changed).toBe(false); + expect(output.code).not.toMatch(RUNTIME); + expect(output.code).toContain("export async function save(form)"); + expect(output.code).toContain("export const remove = async id =>"); + expect(output.code).toContain("export default async function ()"); + expect(output.code).toContain("function* ids()"); + expect(output.code).toContain("async function* rows()"); + expect(output.code).toContain("for await (const row of source())"); + expect(output.code).toContain("static load = async () =>"); + }); + } + + it("does not change a server action in a function, but changes the function around it", async () => { + const output = await transform(`export default async function Page() { + const data = await load(); + async function act(form) { + "use server"; + await db.write(form); + } + return [data, act]; +}`); + expect(output.changed).toBe(true); + expect(output.code).toContain("_coroutine(function* ()"); + expect(output.code).toMatch(/async function act\(form\) \{\s*"use server";\s*await db\.write\(form\);\s*\}/); + expect(output.code.split("_coroutine(")).toHaveLength(2); + }); + + it("does not change a use cache function, but changes the other async functions of the file", async () => { + const output = await transform(`export async function cached(id) { + "use cache: private"; + return await fetch(id); +} +export async function plain(id) { + return await fetch(id); +}`); + expect(output.changed).toBe(true); + expect(output.code).toMatch(/export async function cached\(id\) \{\s*"use cache: private";\s*return await fetch\(id\);\s*\}/); + expect(output.code).toContain("export function plain("); + expect(output.code).toContain("_coroutine(function* (id)"); + }); + + it("does not change the generators, the async generators, the classes and the using declarations in a server function", async () => { + const output = await transform(`export async function act() { + "use server"; + await using resource = open(); + function* ids() { yield 1; } + async function* rows() { for await (const row of source()) yield row; } + class Store { static load = async () => { await null; return this; }; } + const run = async () => { await null; }; + return [ids, rows, Store, run, resource]; +}`); + expect(output.changed).toBe(false); + expect(output.code).not.toMatch(RUNTIME); + expect(output.code).toContain("await using resource = open();"); + expect(output.code).toContain("function* ids()"); + expect(output.code).toContain("async function* rows()"); + expect(output.code).toContain("for await (const row of source())"); + expect(output.code).toContain("static load = async () =>"); + expect(output.code).toContain("const run = async () =>"); + }); + + it("does not change server methods of objects and classes", async () => { + const output = await transform(`export const actions = { async save() { "use server"; await null; } }; +export class Data { static async load() { "use cache"; await null; } }`); + expect(output.changed).toBe(false); + expect(output.code).not.toMatch(RUNTIME); + expect(output.code).toContain("async save()"); + expect(output.code).toContain("static async load()"); + }); + + it("does not move a server arrow function of a static class field, but moves the other async arrow functions", async () => { + const output = await transform(`export class Data { + static save = async () => { "use server"; await null; }; + static load = async () => { await null; return this; }; +}`); + expect(output.code).toMatch(/static save = async \(\) => \{\s*"use server";\s*await null;\s*\};/); + expect(output.code).toContain("static load = this.#asyncContext1();"); + expect(output.code.split("#asyncContext")).toHaveLength(3); + }); + + it("changes a function with a server text that is not a directive, and a use client file", async () => { + const late = await transform("async function f() { await null; \"use server\"; }"); + const client = await transform("\"use client\";\nexport async function f() { await null; }"); + expect(late.code).toContain("_coroutine("); + expect(client.code).toContain("_coroutine("); + }); + + it("does not stop the other plugins of the pass in server code", async () => { + // As @babel/preset-typescript and @babel/preset-react do: a plugin in the same traversal + const rename : PluginObject = { + visitor : { + Identifier(path) { + if (path.node.name === "secret") { + path.node.name = "renamed"; + } + }, + }, + }; + const file = await transform("\"use server\";\nexport async function act() { await secret(); }", [rename]); + const inline = await transform("export async function act() { \"use server\"; await secret(); }", [rename]); + expect(file.code).toContain("await renamed();"); + expect(inline.code).toContain("await renamed();"); + expect(file.code).not.toMatch(RUNTIME); + expect(inline.code).not.toMatch(RUNTIME); + }); + + it("finds a server directive also without the text of the file (transformFromAst)", async () => { + const code = "\"use server\";\nexport async function act() { await null; }"; + const ast = parseSync(code, { babelrc : false, configFile : false, sourceType : "module", filename : "input.js" }); + const result = await transformFromAstAsync(ast as NonNullable, undefined, { + filename : "input.js", + babelrc : false, + configFile : false, + presets : [preset], + }); + expect(result?.code).toContain("export async function act()"); + expect(result?.code).not.toMatch(RUNTIME); + }); +}); + +describe("withServerCodeSkipped", () => { + /** A plugin that counts the calls of each visitor form: a function, an array of functions, and enter and exit. */ + function countingPlugin(calls : Record, shouldSkip : () => boolean) : PluginFactory { + const count = (name : string) => () : void => { + calls[name] = (calls[name] ?? 0) + 1; + }; + return () => ({ + visitor : { + noScope : true, + shouldSkip, + StringLiteral : count("function"), + NumericLiteral : { enter : [count("enter array")], exit : count("exit") }, + "Identifier|ThisExpression" : { enter : count("enter") }, + } as unknown as PluginObject["visitor"], + }); + } + + it("keeps the keys that are not visitor functions", () => { + const shouldSkip = () : boolean => false; + const plugin = withServerCodeSkipped(countingPlugin({}, shouldSkip))({} as never, {}, ""); + const visitor = plugin.visitor as Record; + expect(visitor["noScope"]).toBe(true); + expect(visitor["shouldSkip"]).toBe(shouldSkip); + }); + + it("gives a plugin without a visitor as it is", () => { + const plugin : PluginObject = { name : "empty" }; + expect(withServerCodeSkipped(() => plugin)({} as never, {}, "")).toBe(plugin); + }); + + it("operates each visitor form outside server code, and none in server code", async () => { + const run = async (code : string) : Promise> => { + const calls : Record = {}; + await transformAsync(code, { + filename : "input.js", + babelrc : false, + configFile : false, + plugins : [withServerCodeSkipped(countingPlugin(calls, () => false))], + }); + return calls; + }; + const body = "export function f(a) { return ['x', 1, a]; }"; + expect(await run(body)).toEqual({ "function" : 1, "enter array" : 1, "exit" : 1, "enter" : 2 }); + expect(await run(`"use server";\n${body}`)).toEqual({}); + expect(await run("export async function f(a) { \"use server\"; return ['x', 1, a]; }")).toEqual({ "enter" : 1 }); + }); +}); diff --git a/src/babel/server-code.ts b/src/babel/server-code.ts new file mode 100644 index 0000000..30e81dd --- /dev/null +++ b/src/babel/server-code.ts @@ -0,0 +1,121 @@ +import { types, type NodePath, type PluginPass } from "@babel/core"; +import { isRuntimeFunction } from "./runtime-calls.js"; +import type { PluginFactory } from "./skip-runtime.js"; + +/** + * This function gives `true` for a directive that marks code of the server in React Server + * Components: `"use server"`, `"use cache"` and `"use cache: "`. Next.js reads the same + * three forms. A server action and a `"use cache"` function must stay native async + * functions without `this` and `arguments`. In the bundle of the browser, Next.js replaces them + * with references to the server. On the server, the native `AsyncLocalStorage` keeps the context. + */ +export function isServerDirective(value : string) : boolean { + return value === "use server" || value === "use cache" || value.startsWith("use cache: "); +} + +function hasServerDirectiveIn(directives : readonly types.Directive[]) : boolean { + return directives.some((directive) => isServerDirective(directive.value.value)); +} + +/** This function gives `true` if `node` is a function with a server directive in its body. */ +export function isServerFunction(node : types.Node) : boolean { + return types.isFunction(node) && node.body.type === "BlockStatement" && hasServerDirectiveIn(node.body.directives); +} + +/** This function gives `true` if the preset must not change `node`: a function of the runtime or a server function. */ +export function isUnchangedFunction(node : types.Node) : boolean { + return isRuntimeFunction(node) || isServerFunction(node); +} + +/** The words of a server directive. A file without them has no server code, so the check of its paths is fast. */ +const SERVER_WORDS = /use (?:server|cache)/; + +/** For each file, `false` if its text has no server directive. */ +const mayHaveServerCode = new WeakMap(); + +function fileMayHaveServerCode(state : PluginPass | undefined) : boolean { + const file = state?.file as { code? : unknown } | undefined; + if (file === undefined) { + return true; + } + let result = mayHaveServerCode.get(file); + if (result === undefined) { + // Without the text, for example after transformFromAst, the function examines the paths. Babel then gives "". + result = typeof file.code !== "string" || file.code === "" || SERVER_WORDS.test(file.code); + mayHaveServerCode.set(file, result); + } + return result; +} + +/** + * This function gives `true` if `path` is server code. Server code is in a program with a + * server directive, or in a server function, or it is a server function. + */ +export function isServerCode(path : NodePath) : boolean { + for (let current : NodePath | null = path; current !== null; current = current.parentPath) { + const node = current.node; + if (types.isProgram(node)) { + return hasServerDirectiveIn(node.directives); + } + if (isServerFunction(node)) { + return true; + } + } + return false; +} + +/** The keys of a visitor that are not visitor functions. */ +const NOT_HANDLERS = new Set(["shouldSkip", "denylist", "blacklist", "noScope", "skipKeys", "_exploded", "_verified"]); + +type Handler = (this : PluginPass, path : NodePath, state : PluginPass) => void; + +function guarded(handler : unknown) : unknown { + if (typeof handler !== "function") { + return handler; + } + const original = handler as Handler; + return function serverCodeSkipped(this : PluginPass, path : NodePath, state : PluginPass) : void { + if (fileMayHaveServerCode(state) && isServerCode(path)) { + return; + } + original.call(this, path, state); + }; +} + +function guardedAll(handlers : unknown) : unknown { + return Array.isArray(handlers) ? handlers.map(guarded) : guarded(handlers); +} + +/** + * This function gives a plugin that operates as `factory`, but not on server code (refer to + * `isServerCode`). Each visitor function of the plugin first examines its path. + * + * The function does not use `path.skip()`. All the plugins of a Babel pass share one + * traversal. Thus a skip also stops the other presets in that file, for example + * `@babel/preset-typescript`. The plugins of this library that start their own traversal skip + * the server functions in that traversal (`isUnchangedFunction`). + */ +export function withServerCodeSkipped(factory : PluginFactory) : PluginFactory { + return function serverCodeSkippedPlugin(api, options, dirname) { + const plugin = factory(api, options, dirname); + if (plugin.visitor === undefined) { + return plugin; + } + const visitor : Record = {}; + for (const [key, value] of Object.entries(plugin.visitor)) { + if (NOT_HANDLERS.has(key)) { + visitor[key] = value; + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const { enter, exit } = value as { enter? : unknown; exit? : unknown }; + visitor[key] = { + ...value, + ...(enter === undefined ? {} : { enter : guardedAll(enter) }), + ...(exit === undefined ? {} : { exit : guardedAll(exit) }), + }; + } else { + visitor[key] = guardedAll(value); + } + } + return { ...plugin, visitor : visitor as typeof plugin.visitor }; + }; +} diff --git a/src/babel/skip-runtime.ts b/src/babel/skip-runtime.ts index 54822d1..caae5db 100644 --- a/src/babel/skip-runtime.ts +++ b/src/babel/skip-runtime.ts @@ -1,5 +1,6 @@ import type { NodePath, PluginAPI, PluginObject, PluginPass, types, Visitor } from "@babel/core"; import { isRuntimeFunction } from "./runtime-calls.js"; +import { isUnchangedFunction } from "./server-code.js"; export { RUNTIME_DIRECTIVE } from "./runtime-calls.js"; @@ -19,6 +20,21 @@ function skipRuntimeVisitor() : Visitor { return { Function : skipRuntimeFunction }; } +/** This function skips a function of the runtime or a server function (server-code.ts) in the own traversal of a plugin. */ +function skipUnchangedFunction(functionPath : NodePath) : void { + if (isUnchangedFunction(functionPath.node)) { + functionPath.skip(); + } +} + +/** + * This function gives a new visitor for the own traversal of a plugin. It skips each function + * that the preset must not change. A skip in that traversal does not stop the other plugins. + */ +function skipUnchangedVisitor() : Visitor { + return { Function : skipUnchangedFunction }; +} + /** * The first plugin of the preset. It skips each function with the directive of the runtime * (runtime-calls.ts), so that the plugins of the main traversal do not change it. The plugins @@ -39,8 +55,8 @@ export type PluginFactory = (api : PluginAPI, options : object, dirname : string * The plugin of Babel for async generators changes the async generators and the `for await` * loops in its own traversal. It starts that traversal in its `Program` visitor. The skip of the main * traversal does not apply to that traversal. This function gives a plugin that operates as - * `factory`. But it puts the visitor of `skipRuntimePlugin` in front of each traversal that the `Program` visitor - * starts. + * `factory`. But it puts a visitor in front of each traversal that the `Program` visitor starts. + * That visitor skips the functions of the runtime and the server functions (server-code.ts). * * The function uses the traversal of the Babel that loads the plugin (`api.traverse`). * If the plugin has no `Program` function, it stays as it is. @@ -58,7 +74,7 @@ export function withRuntimeSkipped(factory : PluginFactory) : PluginFactory { const ownTraverse = path.traverse; // An own property of this path object hides the method of the prototype during the visitor path.traverse = function (this : NodePath, inner : Visitor, innerState? : unknown) : void { - Reflect.apply(ownTraverse, this, [api.traverse.visitors.merge([skipRuntimeVisitor() as Visitor, inner]), innerState]); + Reflect.apply(ownTraverse, this, [api.traverse.visitors.merge([skipUnchangedVisitor() as Visitor, inner]), innerState]); } as typeof path.traverse; try { Reflect.apply(program, this, [path, state]); diff --git a/src/babel/static-members.ts b/src/babel/static-members.ts index 0dfaf7a..ccb9dee 100644 --- a/src/babel/static-members.ts +++ b/src/babel/static-members.ts @@ -1,5 +1,6 @@ import { types, type NodePath, type PluginObject, type PluginPass } from "@babel/core"; import { keyName } from "./name-coroutines.js"; +import { isUnchangedFunction } from "./server-code.js"; /** * `@babel/plugin-transform-async-to-generator` keeps `this` and `super` of an async arrow @@ -27,17 +28,18 @@ import { keyName } from "./name-coroutines.js"; /** * This function gives `true` if `element` is or contains an async arrow function whose `this` is the `this` of the class element. - * It gives `false` for a path without a node, for example the value of `static x;`. + * It gives `false` for a path without a node, for example the value of `static x;`. A server + * function stays where it is, because the async transform does not change it (server-code.ts). */ function hasAsyncArrowOfElement(element : NodePath) : boolean { if (element.isArrowFunctionExpression() && element.node.async) { - return true; + return !isUnchangedFunction(element.node); } let found = false; element.traverse({ Function(path) { - if (!path.isArrowFunctionExpression()) { - // A function that is not an arrow function has its own this + if (!path.isArrowFunctionExpression() || isUnchangedFunction(path.node)) { + // A function that is not an arrow function has its own this, and a server function does not change path.skip(); return; } @@ -89,6 +91,12 @@ export function staticMembersPlugin() : PluginObject { visitor : { Program(programPath) { programPath.traverse({ + Function(path) { + // The functions of the runtime and the server functions stay as they are (server-code.ts) + if (isUnchangedFunction(path.node)) { + path.skip(); + } + }, "ClassProperty|ClassPrivateProperty|ClassAccessorProperty"(path : NodePath) { const node = path.node; if (node.static !== true || !hasAsyncArrowOfElement(path.get("value") as NodePath)) { diff --git a/test/bundlers/fixture/next-turbopack-rule/next.config.mjs b/test/bundlers/fixture/next-turbopack-rule/next.config.mjs new file mode 100644 index 0000000..a284540 --- /dev/null +++ b/test/bundlers/fixture/next-turbopack-rule/next.config.mjs @@ -0,0 +1,38 @@ +// The Turbopack rule of the guide "Build tools" for Next.js. The test copies this +// file over the files of `next` and removes `babel.config.js`. Thus SWC compiles the +// code, and babel-loader with only the preset changes the files of the browser that +// have async functions or generators, also in node_modules. + +/** The options of babel-loader for one kind of file. Turbopack gives the options as JSON to the loader. */ +function babel(syntax) { + return { + loader : "babel-loader", + options : { + babelrc : false, + configFile : false, + sourceType : "unambiguous", + parserOpts : { plugins : syntax }, + presets : ["async-browser-context/babel-preset"], + }, + }; +} + +/** The files of the browser that the preset can change. Turbopack does not give the other files to Babel. */ +const condition = { all : ["browser", { content : /\basync\b|\byield\b|function\s*\*/ }] }; + +export default { + cacheComponents : true, + turbopack : { + // The alias of the guide for pnpm settings that hide the library from the dependencies. + // npm puts the library in this folder too, so the alias changes nothing here. The test + // examines that Next.js accepts it. The path goes from the folder of this file. + resolveAlias : { + "async-browser-context/runtime" : { browser : "../node_modules/async-browser-context/dist/runtime.js" }, + }, + rules : { + "*.{js,jsx,mjs,cjs}" : { condition, loaders : [babel(["jsx"])] }, + "*.{ts,mts,cts}" : { condition, loaders : [babel(["typescript"])] }, + "*.tsx" : { condition, loaders : [babel(["typescript", "jsx"])] }, + }, + }, +}; diff --git a/test/bundlers/fixture/next/app/actions.js b/test/bundlers/fixture/next/app/actions.js new file mode 100644 index 0000000..0f84787 --- /dev/null +++ b/test/bundlers/fixture/next/app/actions.js @@ -0,0 +1,9 @@ +"use server"; +// A server action. Next.js accepts only native async functions in a "use server" file, so the +// preset must not change this file. The client bundle gets a reference to the server in place +// of the function. +import { cachedValue } from "./cached.js"; + +export async function serverAction() { + return await cachedValue(); +} diff --git a/test/bundlers/fixture/next/app/cached.js b/test/bundlers/fixture/next/app/cached.js new file mode 100644 index 0000000..38df279 --- /dev/null +++ b/test/bundlers/fixture/next/app/cached.js @@ -0,0 +1,11 @@ +// A module of the server. The preset does not change the "use cache" function, which Next.js +// accepts only as a native async function. It changes the other async function of the module. +async function plainValue() { + await null; + return "A"; +} + +export async function cachedValue() { + "use cache"; + return await plainValue(); +} diff --git a/test/bundlers/fixture/next/app/page.js b/test/bundlers/fixture/next/app/page.js index af5accd..2a7c09a 100644 --- a/test/bundlers/fixture/next/app/page.js +++ b/test/bundlers/fixture/next/app/page.js @@ -1,10 +1,16 @@ "use client"; import { useEffect } from "react"; import { runChecks } from "../checks.js"; +import { serverAction } from "./actions.js"; + +/** The server action gives "A" if the preset did not change it, and if the "use cache" function operates. */ +async function withServerAction(results) { + return { ...results, serverAction : await serverAction() }; +} export default function Page() { useEffect(() => { - runChecks().then((results) => { + runChecks().then(withServerAction).then((results) => { window.__results = results; document.getElementById("results").textContent = JSON.stringify(results); }); diff --git a/test/bundlers/fixture/next/next.config.mjs b/test/bundlers/fixture/next/next.config.mjs index 424c29c..422c556 100644 --- a/test/bundlers/fixture/next/next.config.mjs +++ b/test/bundlers/fixture/next/next.config.mjs @@ -1,3 +1,7 @@ -// The packages in node_modules that Next.js compiles with the Babel config. -// Next.js does not compile the other packages. -export default { transpilePackages : ["fake-dependency"] }; +export default { + // The packages in node_modules that Next.js compiles with the Babel config. + // Next.js does not compile the other packages. + transpilePackages : ["fake-dependency"], + // "use cache" (app/cached.js) operates only with this option. + cacheComponents : true, +}; diff --git a/test/bundlers/run.ts b/test/bundlers/run.ts index e925ef5..277c2d9 100644 --- a/test/bundlers/run.ts +++ b/test/bundlers/run.ts @@ -7,7 +7,10 @@ * 2. It installs the two tarballs and the build tools with npm in a new * folder. * 3. It builds the page with webpack, Rspack, Rollup and esbuild - * (`fixture/build.mjs`), and with Next.js (`fixture/next`). + * (`fixture/build.mjs`), and with Next.js (`fixture/next`). The Next.js + * page also calls a server action, which calls a `"use cache"` function. + * Next.js accepts only native async functions there, so the preset must not + * change them. * 4. It opens each page in Chromium. The page writes the results of * `test/smoke/fixture/checks.js` to `window.__results`. * @@ -55,6 +58,8 @@ const TOOLS : readonly string[] = [ const ALL_RULES : Results = { inExpression : "A", inDependency : "A", generator : "A", browserEvent : "A" }; /** The compiler changes async functions into generators: a generator body gets the context of the `next()` call. */ const WITHOUT_RULE_C5 : Results = { ...ALL_RULES, generator : "B" }; +/** The Next.js page also gives the result of its server action (fixture/next/app/actions.js). */ +const SERVER_ACTION : Results = { serverAction : "A" }; /** The builds of `fixture/build.mjs`. */ const STATIC_CASES : readonly (readonly [string, Results])[] = [ @@ -69,15 +74,20 @@ const STATIC_CASES : readonly (readonly [string, Results])[] = [ interface NextCase { readonly name : string; readonly args : readonly string[]; - /** `babel`: the Babel config of the fixture. `swc`: no Babel config, and a browserslist query that makes SWC change async functions. */ - readonly compiler : "babel" | "swc"; + /** + * - `babel`: the Babel config of the fixture (`babel.config.js`). + * - `turbopack-rule`: no Babel config, and a Turbopack rule that gives the files of the browser to babel-loader with only the preset (`fixture/next-turbopack-rule`). + * - `swc`: no Babel config, and a browserslist query that makes SWC change async functions. + */ + readonly compiler : "babel" | "turbopack-rule" | "swc"; readonly expected : Results; } const NEXT_CASES : readonly NextCase[] = [ - { name : "next-turbopack-babel", args : [], compiler : "babel", expected : ALL_RULES }, - { name : "next-webpack-babel", args : ["--webpack"], compiler : "babel", expected : ALL_RULES }, - { name : "next-turbopack-swc", args : [], compiler : "swc", expected : WITHOUT_RULE_C5 }, + { name : "next-turbopack-babel", args : [], compiler : "babel", expected : { ...ALL_RULES, ...SERVER_ACTION } }, + { name : "next-webpack-babel", args : ["--webpack"], compiler : "babel", expected : { ...ALL_RULES, ...SERVER_ACTION } }, + { name : "next-turbopack-rule", args : [], compiler : "turbopack-rule", expected : { ...ALL_RULES, ...SERVER_ACTION } }, + { name : "next-turbopack-swc", args : [], compiler : "swc", expected : { ...WITHOUT_RULE_C5, ...SERVER_ACTION } }, ]; /** Runs a command in a shell (npm is a .cmd file on Windows). Each argument is in quotes. */ @@ -136,11 +146,16 @@ async function withNextServer(directory : string, next : string, env : NodeJS function nextApp(app : string, testCase : NextCase) : string { const directory = path.join(app, testCase.name); fs.cpSync(path.join(app, "next"), directory, { recursive : true }); + if (testCase.compiler === "turbopack-rule") { + // Without a Babel config, SWC compiles the code, and the rule of next.config.mjs gives the files of the browser to the preset + fs.rmSync(path.join(directory, "babel.config.js")); + fs.cpSync(path.join(app, "next-turbopack-rule"), directory, { recursive : true }); + } if (testCase.compiler === "swc") { // Without a Babel config, SWC compiles the code. A browser without async functions // in the query makes SWC change them into generators, also in node_modules. fs.rmSync(path.join(directory, "babel.config.js")); - fs.rmSync(path.join(directory, "next.config.mjs")); + fs.writeFileSync(path.join(directory, "next.config.mjs"), "export default { cacheComponents : true };\n"); fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify({ private : true, browserslist : ["chrome 54"] })); } return directory; @@ -186,7 +201,10 @@ async function checkCases(app : string, browser : Browser) : Promise { const env = { ...process.env, NEXT_TELEMETRY_DISABLED : "1" }; for (const testCase of NEXT_CASES) { const directory = nextApp(app, testCase); + const start = performance.now(); execFileSync(process.execPath, [next, "build", ...testCase.args], { cwd : directory, stdio : ["ignore", "ignore", "inherit"], env }); + // One build of a small page on a CI computer: a rough comparison of the setups, not a benchmark + console.log(`${testCase.name}: next build took ${((performance.now() - start) / 1000).toFixed(1)} s`); const results = await withNextServer(directory, next, env, (url) => resultsOf(browser, url)); passed = check(testCase.name, results, testCase.expected) && passed; } From 70fe272d790031519c613cd6b4d27d8c3836e255 Mon Sep 17 00:00:00 2001 From: Mark Russell Date: Sat, 14 Nov 2026 20:04:26 -0500 Subject: [PATCH 2/3] fix(next): give the browsers of Next.js to preset-env with webpack With webpack, next/babel compiles the browser code with @babel/preset-env, and Next.js gives it no targets. preset-env then changes the async functions of a server action, and Next.js stops the build ("Server Actions must be async functions"), also without the preset of this library. The fixture and the guide now give the default browsers of Next.js 16. The test of withServerCodeSkipped no longer gives noScope to a plugin visitor (Babel refuses it there), and counts each identifier. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- site/content/docs/guides/build-tools.mdx | 9 +++++-- src/babel/server-code.test.ts | 28 +++++++++++++--------- test/bundlers/fixture/next/babel.config.js | 8 ++++++- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e037bde..bae4c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This version fixes a bug, so it is a patch version. - The Babel preset does not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. Next.js accepts only native async functions there. Before, the preset changed them into functions that are not async and that use `this` and `arguments`. Next.js then stopped the build, also when a Turbopack rule gave only the files of the browser to the preset. The other presets and plugins of the configuration still change this code. The Vite plugin uses the preset, so it gets the same fix. - The guide "Build tools" recommends a Turbopack rule with `babel-loader` for Next.js. It also tells the pnpm settings with which a dependency finds the runtime, and how to make Babel do less work. CI builds the rule with a server action and a `"use cache"` function. +- The Babel configuration file for Next.js with webpack now gives the default browsers of Next.js to `@babel/preset-env` in `next/babel`. Without targets, preset-env changes the async functions of a server action, and Next.js stops the build. ## 0.3.1 diff --git a/README.md b/README.md index 6ec6467..54599c7 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ A test in CI (`pnpm test:bundlers`) builds a page with each of these setups and | Rollup 4 | `@rollup/plugin-babel` with the preset | All | | esbuild | A small plugin that applies the preset | All | | Next.js 16, Turbopack | A `turbopack.rules` rule: `babel-loader` with only the preset, for the files of the browser | All | -| Next.js 16, webpack | `babel.config.js` with `next/babel` and the preset, and `transpilePackages` for the dependencies | All | +| Next.js 16, webpack | `babel.config.js` with `next/babel` (with the browsers of Next.js as `preset-env` targets) and the preset, and `transpilePackages` for the dependencies | All | | SWC, esbuild, Next.js without Babel | The target ES2016: the compiler changes async functions into generators | All except rule C5 | With the target ES2016, the patch of `then` keeps the context through `await`. A generator body then gets the context of each `next()` call, and the callback hooks get no names. diff --git a/site/content/docs/guides/build-tools.mdx b/site/content/docs/guides/build-tools.mdx index d1c1fd4..020596d 100644 --- a/site/content/docs/guides/build-tools.mdx +++ b/site/content/docs/guides/build-tools.mdx @@ -239,12 +239,15 @@ The build tool test writes the time of each Next.js build to its output. These t ### A Babel configuration file -1. Add a Babel configuration file next to `package.json`, with the preset of Next.js and the preset of the library: +1. Add a Babel configuration file next to `package.json`, with the preset of Next.js and the preset of the library. Give the browsers of Next.js to `@babel/preset-env` in `next/babel`: ```js // babel.config.js module.exports = { - presets : ["next/babel", "async-browser-context/babel-preset"], + presets : [ + ["next/babel", { "preset-env" : { targets : "chrome 111, edge 111, firefox 111, safari 16.4" } }], + "async-browser-context/babel-preset", + ], }; ``` @@ -255,6 +258,8 @@ The build tool test writes the time of each Next.js build to its output. These t export default { transpilePackages : ["some-dependency"] }; ``` +With webpack, `next/babel` also compiles the code of the browser with `@babel/preset-env`. Next.js does not give it the browsers of its SWC configuration. Without `targets` and without a `browserslist` query, preset-env changes all the code for old browsers. It also changes the async functions of each server action that a client component imports. Next.js then stops the build with the error "Server Actions must be async functions". This occurs also without the preset of the library. The `targets` above are the default browsers of Next.js 16. A `browserslist` query in `package.json` can replace them, but each browser in the query must have native async functions. On the server, `next/babel` uses the version of Node.js that builds the app. + With a Babel configuration file, Next.js uses Babel in place of SWC for all your code, with Turbopack and with webpack. The build takes more time. Next.js does not apply the Babel configuration to the other packages in `node_modules`. With Turbopack, use [the rule](#a-turbopack-rule): SWC then compiles all the other files. The server code also gets the transform. On the server, the `node` export condition selects the Node.js entry of the library, which uses the native `AsyncLocalStorage`. The preset does not change server actions and `"use cache"` functions (refer to [Server Actions](#server-actions-and-use-cache)). diff --git a/src/babel/server-code.test.ts b/src/babel/server-code.test.ts index 652e410..dd74c83 100644 --- a/src/babel/server-code.test.ts +++ b/src/babel/server-code.test.ts @@ -157,7 +157,8 @@ export class Data { static async load() { "use cache"; await null; } }`); it("finds a server directive also without the text of the file (transformFromAst)", async () => { const code = "\"use server\";\nexport async function act() { await null; }"; const ast = parseSync(code, { babelrc : false, configFile : false, sourceType : "module", filename : "input.js" }); - const result = await transformFromAstAsync(ast as NonNullable, undefined, { + // Without the text, Babel gives the file the code "" + const result = await transformFromAstAsync(ast as NonNullable, "", { filename : "input.js", babelrc : false, configFile : false, @@ -170,27 +171,30 @@ export class Data { static async load() { "use cache"; await null; } }`); describe("withServerCodeSkipped", () => { /** A plugin that counts the calls of each visitor form: a function, an array of functions, and enter and exit. */ - function countingPlugin(calls : Record, shouldSkip : () => boolean) : PluginFactory { + function countingPlugin(calls : Record, keys : Record = {}) : PluginFactory { const count = (name : string) => () : void => { calls[name] = (calls[name] ?? 0) + 1; }; return () => ({ visitor : { - noScope : true, - shouldSkip, + ...keys, StringLiteral : count("function"), NumericLiteral : { enter : [count("enter array")], exit : count("exit") }, "Identifier|ThisExpression" : { enter : count("enter") }, - } as unknown as PluginObject["visitor"], - }); + }, + }) as unknown as PluginObject; } - it("keeps the keys that are not visitor functions", () => { + // Babel accepts these keys only in the visitor of path.traverse, not in a plugin. Thus this test does not start Babel. + it("keeps the keys that are not visitor functions, for example the marks of a prepared visitor", () => { const shouldSkip = () : boolean => false; - const plugin = withServerCodeSkipped(countingPlugin({}, shouldSkip))({} as never, {}, ""); + const keys = { noScope : true, shouldSkip, _exploded : true, _verified : true }; + const plugin = withServerCodeSkipped(countingPlugin({}, keys))({} as never, {}, ""); const visitor = plugin.visitor as Record; expect(visitor["noScope"]).toBe(true); expect(visitor["shouldSkip"]).toBe(shouldSkip); + expect(visitor["_exploded"]).toBe(true); + expect(visitor["_verified"]).toBe(true); }); it("gives a plugin without a visitor as it is", () => { @@ -205,13 +209,15 @@ describe("withServerCodeSkipped", () => { filename : "input.js", babelrc : false, configFile : false, - plugins : [withServerCodeSkipped(countingPlugin(calls, () => false))], + plugins : [withServerCodeSkipped(countingPlugin(calls))], }); return calls; }; + // The identifiers f, a and a const body = "export function f(a) { return ['x', 1, a]; }"; - expect(await run(body)).toEqual({ "function" : 1, "enter array" : 1, "exit" : 1, "enter" : 2 }); + expect(await run(body)).toEqual({ "function" : 1, "enter array" : 1, "exit" : 1, "enter" : 3 }); expect(await run(`"use server";\n${body}`)).toEqual({}); - expect(await run("export async function f(a) { \"use server\"; return ['x', 1, a]; }")).toEqual({ "enter" : 1 }); + // Only the identifiers g, b and b are outside the server function. Its name f is in its node. + expect(await run("function g(b) { async function f(a) { \"use server\"; return ['x', 1, a, this]; } return b; }")).toEqual({ "enter" : 3 }); }); }); diff --git a/test/bundlers/fixture/next/babel.config.js b/test/bundlers/fixture/next/babel.config.js index a9dbd34..5a6b669 100644 --- a/test/bundlers/fixture/next/babel.config.js +++ b/test/bundlers/fixture/next/babel.config.js @@ -1,5 +1,11 @@ // The Babel setup of the guide "Build tools" for Next.js. With this file, // Next.js uses Babel in place of SWC, also with Turbopack. +// With webpack, next/babel also starts @babel/preset-env. Without targets, it +// changes the async functions of a server action, and Next.js stops the build. +// These targets are the default browsers of Next.js 16. module.exports = { - presets : ["next/babel", "async-browser-context/babel-preset"], + presets : [ + ["next/babel", { "preset-env" : { targets : "chrome 111, edge 111, firefox 111, safari 16.4" } }], + "async-browser-context/babel-preset", + ], }; From 06a2340062e5681eccb0793529e4583c3da6f370 Mon Sep 17 00:00:00 2001 From: Mark Russell Date: Sat, 14 Nov 2026 20:10:24 -0500 Subject: [PATCH 3/3] test(bundlers): give the rule fixture a folder name that is not a case name The case next-turbopack-rule and its fixture folder had the same name, so the case folder was the fixture folder, and the copy failed ("src and dest cannot be the same"). The fixture is now fixture/next-rule. Also fix two STE findings in the CHANGELOG and the guide. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- site/content/docs/guides/build-tools.mdx | 8 +++++--- .../{next-turbopack-rule => next-rule}/next.config.mjs | 0 test/bundlers/run.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) rename test/bundlers/fixture/{next-turbopack-rule => next-rule}/next.config.mjs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bae4c15..66f3767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This version fixes a bug, so it is a patch version. - The Babel preset does not change the server code of React Server Components: a file or a function with the directive `"use server"`, `"use cache"` or `"use cache: "`. Next.js accepts only native async functions there. Before, the preset changed them into functions that are not async and that use `this` and `arguments`. Next.js then stopped the build, also when a Turbopack rule gave only the files of the browser to the preset. The other presets and plugins of the configuration still change this code. The Vite plugin uses the preset, so it gets the same fix. - The guide "Build tools" recommends a Turbopack rule with `babel-loader` for Next.js. It also tells the pnpm settings with which a dependency finds the runtime, and how to make Babel do less work. CI builds the rule with a server action and a `"use cache"` function. -- The Babel configuration file for Next.js with webpack now gives the default browsers of Next.js to `@babel/preset-env` in `next/babel`. Without targets, preset-env changes the async functions of a server action, and Next.js stops the build. +- The Babel configuration file for Next.js with webpack gives the default browsers of Next.js to `@babel/preset-env` in `next/babel`. Without targets, preset-env changes the async functions of a server action, and Next.js stops the build. ## 0.3.1 diff --git a/site/content/docs/guides/build-tools.mdx b/site/content/docs/guides/build-tools.mdx index 020596d..a12f3fe 100644 --- a/site/content/docs/guides/build-tools.mdx +++ b/site/content/docs/guides/build-tools.mdx @@ -31,7 +31,7 @@ The command `pnpm test:bundlers` builds the same page with each setup, and opens | Next.js 16, webpack | A Babel configuration file | All rules | | Next.js 16, Turbopack | A `browserslist` query with a browser without async functions | All rules except rule C5 | -The configurations of the test are in `test/bundlers/fixture/build.mjs`, `test/bundlers/fixture/next/` and `test/bundlers/fixture/next-turbopack-rule/`. +The configurations of the test are in `test/bundlers/fixture/build.mjs`, `test/bundlers/fixture/next/` and `test/bundlers/fixture/next-rule/`. ## Vite @@ -195,7 +195,7 @@ export default { - The `browser` condition selects the code of the browser. On the server, the `node` export condition selects the Node.js entry of the library, which uses the native `AsyncLocalStorage`. The server code thus does not need the transform. - The rule also applies to the packages in `node_modules`. To exclude a file or a package, add a condition to the `all` list, for example `{ not : { path : /node_modules\/some-package\// } }`. `{ not : "foreign" }` excludes all of `node_modules`. A callback that a package starts after its own `await` then gets the root context (refer to [the dependencies](#the-dependencies)). -The configuration of the test is in `test/bundlers/fixture/next-turbopack-rule/next.config.mjs`. +The configuration of the test is in `test/bundlers/fixture/next-rule/next.config.mjs`. ### Server Actions and `"use cache"` @@ -258,7 +258,9 @@ The build tool test writes the time of each Next.js build to its output. These t export default { transpilePackages : ["some-dependency"] }; ``` -With webpack, `next/babel` also compiles the code of the browser with `@babel/preset-env`. Next.js does not give it the browsers of its SWC configuration. Without `targets` and without a `browserslist` query, preset-env changes all the code for old browsers. It also changes the async functions of each server action that a client component imports. Next.js then stops the build with the error "Server Actions must be async functions". This occurs also without the preset of the library. The `targets` above are the default browsers of Next.js 16. A `browserslist` query in `package.json` can replace them, but each browser in the query must have native async functions. On the server, `next/babel` uses the version of Node.js that builds the app. +With webpack, `next/babel` also compiles the code of the browser with `@babel/preset-env`. Next.js does not give it the browsers of its SWC configuration. Without `targets` and without a `browserslist` query, preset-env changes all the code for old browsers. It also changes the async functions of each server action that a client component imports. Next.js then stops the build with the error "Server Actions must be async functions". This occurs also without the preset of the library. + +The `targets` above are the default browsers of Next.js 16. A `browserslist` query in `package.json` can replace them, but each browser in the query must have native async functions. On the server, `next/babel` uses the version of Node.js that builds the app. With a Babel configuration file, Next.js uses Babel in place of SWC for all your code, with Turbopack and with webpack. The build takes more time. Next.js does not apply the Babel configuration to the other packages in `node_modules`. With Turbopack, use [the rule](#a-turbopack-rule): SWC then compiles all the other files. diff --git a/test/bundlers/fixture/next-turbopack-rule/next.config.mjs b/test/bundlers/fixture/next-rule/next.config.mjs similarity index 100% rename from test/bundlers/fixture/next-turbopack-rule/next.config.mjs rename to test/bundlers/fixture/next-rule/next.config.mjs diff --git a/test/bundlers/run.ts b/test/bundlers/run.ts index 277c2d9..a3e84dc 100644 --- a/test/bundlers/run.ts +++ b/test/bundlers/run.ts @@ -76,7 +76,7 @@ interface NextCase { readonly args : readonly string[]; /** * - `babel`: the Babel config of the fixture (`babel.config.js`). - * - `turbopack-rule`: no Babel config, and a Turbopack rule that gives the files of the browser to babel-loader with only the preset (`fixture/next-turbopack-rule`). + * - `turbopack-rule`: no Babel config, and a Turbopack rule that gives the files of the browser to babel-loader with only the preset (`fixture/next-rule`). * - `swc`: no Babel config, and a browserslist query that makes SWC change async functions. */ readonly compiler : "babel" | "turbopack-rule" | "swc"; @@ -149,7 +149,7 @@ function nextApp(app : string, testCase : NextCase) : string { if (testCase.compiler === "turbopack-rule") { // Without a Babel config, SWC compiles the code, and the rule of next.config.mjs gives the files of the browser to the preset fs.rmSync(path.join(directory, "babel.config.js")); - fs.cpSync(path.join(app, "next-turbopack-rule"), directory, { recursive : true }); + fs.cpSync(path.join(app, "next-rule"), directory, { recursive : true }); } if (testCase.compiler === "swc") { // Without a Babel config, SWC compiles the code. A browser without async functions