Skip to content

[unplugin] Internal Babel pass discovers root babel.config.* — no opt-out #1792

Description

@jonreading81

Describe the issue

@stylexjs/unplugin runs its own internal Babel pass over every StyleX file (to apply the StyleX Babel plugin) by calling @babel/core's transformAsync. That call hardcodes babelrc: false, but not configFile: false — so Babel still discovers and applies any root babel.config.js / .cjs / .mjs / .ts present in the project.

For any project with a root Babel config (Vite templates, Next.js apps, monorepos with a shared Babel config, most non-toy setups), this silently causes every plugin and preset in that root config — react-compiler, relay, module-resolver, Flow/TS stripping, etc. — to run against every StyleX file inside the StyleX pass, in addition to running normally in the consumer's main Vite/Rollup/webpack pipeline.

Symptoms range from wasted CPU (we measured ~15-30% of a Vite dev warm build going to this double-transform) to real correctness bugs (React Compiler running twice, module-resolver producing subtly different rewrites depending on which pass runs first).

There is currently no way to opt the StyleX Babel pass out of this — the transformAsync options aren't exposed on the plugin's user options at all.

Expected behavior

Consumers should be able to opt the plugin's internal Babel pass out of root-config discovery — for example via configFile: false — without having to fork or pnpm patch the plugin. Ideally the plugin would also default to configFile: false (since the plugin's caller shouldn't need the consumer's root Babel config to do its work), but that's a bigger behavioural change; the immediate fix is just to make it configurable.

Steps to reproduce

Package versions: @stylexjs/unplugin@0.19.0, @babel/core@7.x.

  1. Create a Vite project that uses @stylexjs/unplugin/vite.
  2. Add a root babel.config.cjs (typical for a React-Compiler / Relay project):
    module.exports = {
      plugins: ['babel-plugin-react-compiler', 'relay'],
    };
  3. Add any file that imports @stylexjs/stylex and calls stylex.create(...).
  4. Instrument either plugin (a console.log in the plugin's visitor or wrapping the plugin function) to log when it runs, or profile the build.

Observed: react-compiler and relay run once during the StyleX unplugin's internal Babel pass, and again in the main Vite React pipeline. Same for any other plugin/preset in the root config.

Expected: the StyleX unplugin's internal Babel pass should not apply plugins/presets the caller didn't ask for — or at minimum should expose a way to opt out.

Test case

Any Vite + React-Compiler + StyleX project reproduces it. The direct code path is packages/@stylexjs/unplugin/src/core.js in runBabelTransform:

const result = await transformAsync(inputCode, {
  babelrc: false,     // only disables .babelrc* files; root babel.config.* still discovered
  filename,
  presets,
  plugins: [...],
  caller: {...},
});

Additional comments

I've opened #1791 with a proposed fix: a babel option on the plugin's user options that is spread into transformAsync before the plugin's mandatory options (so consumers can extend but not override the plugin's internals). The primary use case is babel: { configFile: false }.

Happy to iterate on API naming or approach — an alternative would be to default configFile: false inside the plugin and have consumers opt back in if they want the root config applied. That's more aggressive but arguably more correct. Flagging both for maintainer preference.

We've been running the patched version at Mixcloud for a few weeks and it cleanly resolves both the perf and correctness issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions