Skip to content

@stylexjs/unplugin lightningcss targets should respect project browserslist + handle light-dark() polyfill correctly #1524

@cixzhang

Description

@cixzhang

Describe the issue

Problem

The unplugin's internal lightningcss transform hardcodes browserslist('>= 1%') as its default targets:

// node_modules/@stylexjs/unplugin/lib/index.js
const { code } = transform({
  targets: browserslistToTargets(browserslist('>= 1%')),
  ...options.lightningcssOptions,
  filename: 'stylex.css',
  code: Buffer.from(collectedCSS),
});

This causes two issues:

1. Ignores the project's browserslist config

Every other tool in the CSS/JS ecosystem reads the project's browserslist config automatically:

  • Autoprefixerbrowserslist() (reads .browserslistrc / package.json)
  • Babel preset-env — reads browserslist config
  • PostCSS preset-env — reads browserslist config
  • Next.js — reads browserslist config for CSS processing
  • Vite — reads browserslist for JS targets

The browserslist standard resolution order is:

  1. BROWSERSLIST env variable
  2. browserslist key in package.json
  3. .browserslistrc file
  4. browserslist.config.js
  5. Default query (> 0.5%, last 2 versions, Firefox ESR, not dead)

@stylexjs/unplugin skips all of this and hardcodes >= 1%, which resolves to Chrome 112 — a browser that doesn't support CSS light-dark(). This means the consumer's explicitly declared browser targets are silently ignored for the CSS that StyleX extracts.

Suggested fix: Replace the hardcoded query with a browserslist() call (no arguments), which automatically reads the project's config. Fall back to the browserslist defaults if no config is found.

// Before
targets: browserslistToTargets(browserslist('>= 1%')),

// After
targets: browserslistToTargets(browserslist()),

2. light-dark() polyfill is broken when lowered

When the targets include browsers that don't support light-dark() (Chrome < 123), lightningcss lowers it into a polyfill using CSS custom properties:

/* Input */
color: light-dark(#000, #fff);

/* Output */
color: var(--lightningcss-light, #000) var(--lightningcss-dark, #fff);

This polyfill works by toggling --lightningcss-light / --lightningcss-dark between initial and empty string. The toggle initialization is injected wherever lightningcss sees a color-scheme declaration:

/* lightningcss injects this when it sees color-scheme */
:root {
  --lightningcss-light: initial;
  --lightningcss-dark: ;
  color-scheme: light dark;
}

The problem: The color-scheme declaration typically lives in a separate CSS file (e.g. a reset stylesheet or theme provider), not in the StyleX-extracted CSS. Since the unplugin runs lightningcss only on the collected StyleX rules, it never sees color-scheme, so the toggle variables are never initialized. The polyfill vars are used but never defined — every light-dark() color silently becomes empty.

Suggested fix: When the collected CSS contains light-dark() values, prepend :root { color-scheme: light dark; } before passing it to lightningcss. This way lightningcss sees both in the same transform call, injects the polyfill init alongside the lowered values, and the polyfill works correctly regardless of what other CSS files the consumer has.

function processCollectedRulesToCSS(rules, options) {
  if (!rules || rules.length === 0) return '';
  let collectedCSS = stylexBabelPlugin.processStylexRules(rules, { /* ... */ });

  // If the CSS contains light-dark(), ensure color-scheme is present
  // so lightningcss can generate a working polyfill
  if (collectedCSS.includes('light-dark(')) {
    collectedCSS = ':root { color-scheme: light dark; }\n' + collectedCSS;
  }

  const { code } = transform({
    targets: browserslistToTargets(browserslist()),
    ...options.lightningcssOptions,
    filename: 'stylex.css',
    code: Buffer.from(collectedCSS),
  });
  return code.toString();
}

Reproduction

Any stylex.defineVars() with light-dark() values triggers this:

// tokens.stylex.ts
export const colors = stylex.defineVars({
  '--color-surface': 'light-dark(#FFFFFF, #1F1F22)',
  '--color-text': 'light-dark(#000000, #FFFFFF)',
});

With default unplugin config (no lightningcssOptions), the production build produces:

:root { --color-surface: var(--lightningcss-light, #fff) var(--lightningcss-dark, #1f1f22); }

The --lightningcss-light / --lightningcss-dark variables are never initialized, so all colors are empty.

Current workaround

Consumers must manually configure lightningcssOptions.targets to prevent lowering:

stylex.vite({
  lightningcssOptions: {
    targets: {
      chrome: 123 << 16,
      firefox: 120 << 16,
      safari: (17 << 16) | (5 << 8),
    },
  },
});

This is non-obvious and not documented. It also prevents the polyfill from working for consumers who genuinely need to support older browsers.

Environment

  • @stylexjs/unplugin: 0.17.4
  • lightningcss: 1.32.0
  • Vite 5.4.x

Expected behavior

  1. The unplugin's internal lightningcss transform should respect the project's browserslist config (.browserslistrc, package.json browserslist field, etc.) instead of hardcoding browserslist('>= 1%'). This is the standard convention used by Autoprefixer, Babel preset-env, PostCSS preset-env, Next.js, and Vite.

  2. When the unplugin does lower light-dark() for older browsers, the resulting CSS polyfill should be self-contained — the --lightningcss-light / --lightningcss-dark toggle variables should be initialized in the same CSS output so the polyfill actually works.

Steps to reproduce

  1. Create a stylex.defineVars() with light-dark() values (see test case above)
  2. Configure the Vite StyleX unplugin with defaults (no lightningcssOptions)
  3. Run a production build (npx vite build)
  4. Inspect the output CSS — light-dark() has been lowered to var(--lightningcss-light, ...) / var(--lightningcss-dark, ...) polyfill variables
  5. The toggle variables --lightningcss-light / --lightningcss-dark are never initialized anywhere in the output CSS
  6. All colors using light-dark() render as empty/invisible

Adding "browserslist": ["last 1 Chrome version"] to package.json has no effect — the unplugin ignores it.

Test case

Any stylex.defineVars() with light-dark() values, built with default unplugin config (no lightningcssOptions):

// tokens.stylex.ts
import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  '--color-surface': 'light-dark(#FFFFFF, #1F1F22)',
  '--color-text': 'light-dark(#000000, #FFFFFF)',
});
// vite.config.ts
import stylex from '@stylexjs/unplugin';

export default defineConfig({
  plugins: [
    stylex.vite({
      dev: false,
      runtimeInjection: false,
      // No lightningcssOptions — using defaults
    }),
    react(),
  ],
});

Production build output:

/* light-dark() lowered to polyfill vars — but toggle vars never initialized */
:root { --color-surface: var(--lightningcss-light, #fff) var(--lightningcss-dark, #1f1f22); }

--lightningcss-light and --lightningcss-dark are never defined, so all colors are empty.

Additional comments

Generated this from Navi. I think the first part about respecting the browserlist makes sense. But I'm not sure about the true solution for handling light-dark. There is a workaround that includes keeping the polyfill by setting up lightningcss as a postprocessor on other non-stylex styles, but this seems non-ideal from a consumer POV since it requires implicit knowledge of stylex's build.

I will also note that this sort of issue affects source distribution for component libraries in particular. Built distributions wouldn't have this problem since those can be built into normal CSS then if there's any postprocessing or polyfills, that's handled by the project's own configuration.

For now we'll likely provide explicit targets in the stylex plugin config to prevent the polyfill which is fine for our current usage but may not be ideal for other OS usage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions