facebook / facebook/stylex

Compiling uncompiled-StyleX libraries in node_modules forces resolution of their full import graph (breaks browser-only / prebundled transitive deps)

Open
#1,754 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
10.3k
Forks
481
Avg merge
3d 8h
Merged PRs (30d)
13

Description

### Describe the feature request

*(Consolidates my earlier discussions #1499 and #1505 — they turned out to be two symptoms of one root cause, and both have since caused production incidents, so I'm filing the underlying constraint as a single trackable issue.)*

## The constraint

We distribute a design system as **uncompiled StyleX**: the library ships transpiled JS with `stylex.create()` / `stylex.props()` calls intact, and each consumer compiles it with `@stylexjs/babel-plugin` via a webpack `babel-loader` rule scoped to the library packages in `node_modules` (plus Next.js `transpilePackages`). As far as we can tell this is the intended distribution model for StyleX component libraries — atomic dedup and style merging only work when app + library compile in one pass.

The problem: the webpack rule's `include` pattern only controls **which files get the Babel pass**. It does **not** stop webpack from **resolving and processing the entire transitive import graph** of those files. So the consumer's webpack always walks the full dependency graph of the component library — including dependencies that are completely unrelated to StyleX — regardless of `'use client'` boundaries (which are compiled away before Next.js can enforce them) and regardless of whether the importing component is ever rendered.

Any StyleX library that wraps a browser-only or prebundled third-party dependency (charting, maps, rich text editors, PDF renderers) inherits this. In our case the dependency was Highcharts, and the single behavior produced **two distinct production crashes** in consumer Next.js apps:

## Symptom 1 — server-side evaluation of browser-only code (#1505)

During `next build` static prerendering, webpack pulled Highcharts into the server bundle and Node evaluated it at module scope, where it touches `window` / `document` / `SVGElement`:

```
TypeError: Cannot read properties of undefined (reading 'Templating')
```

Mitigation we ship in the library: a `typeof window === 'undefined'` guard plus dynamic `import()` for every Highcharts module, with components rendering a placeholder until init resolves. It works, but it means every StyleX library author wrapping a browser-only dependency must discover and re-implement this pattern — and a single accidental static import anywhere in the graph silently reintroduces the crash (it never reproduces in the library's own build or tests, only in a consumer's).

## Symptom 2 — re-bundling a webpack-prebundled artifact (#1499)

The same graph walk made the consumer's webpack re-process `highcharts/esm/highcharts.src.js`, a prebundled artifact carrying its own `__webpack_require__` runtime. Two webpack runtimes collided at client runtime:

```
TypeError: undefined is not a non-null object (evaluating '__webpack_require__.r(...)')
```

Mitigation was a per-dependency `resolve.alias` in every consumer's `next.config.mjs`; it was only truly fixed months later when the dependency itself shipped clean ESM (`@highcharts/react` v5). Per-dependency consumer config doesn't scale as a general answer.

## What we've had to build around it

Beyond the lazy-init pattern, we ended up moving chart components behind a separate package subpath export so the main entry's graph never references the browser-only dependency at all, marking those peers optional, and adding a lint rule + build-time check to keep static imports from creeping back in. That's a lot of machinery for something every StyleX library with a heavy dependency will re-hit.

## The ask

Any of these would resolve or substantially shrink the problem, in descending order of preference:

1. **A transform mode that doesn't force graph resolution** — a way to run the StyleX transform over `node_modules` packages (e.g. a webpack plugin or loader mode) that compiles `stylex.create()` calls in the matched files without causing the bundler to resolve and process the packages' full transitive import graph beyond what the app actually imports.
2. **Scoping configuration** — an option to declare which imports the StyleX pass should follow (e.g. only `@stylexjs/*` and the tokens packages needed for `defineVars` resolution via `unstable_moduleResolution`), leaving unrelated third-party imports untouched.
3. **At minimum, documentation** — the Next.js/webpack guides currently cover app-local StyleX only. First-class docs for the "uncompiled library in `node_modules`" pattern, including the browser-only-transitive-dependency hazard and the recommended mitigation (dynamic imports + window guard), would save the next team the two production incidents it cost us.

## Environment

- `@stylexjs/babel-plugin` / `@stylexjs/stylex` 0.17.x, `runtimeInjection: false`, `treeshakeCompensation: true`, `unstable_moduleResolution: { type: 'commonJS' }`
- Consumer: Next.js (webpack), `babel-loader` rule over the library packages in `node_modules` + `transpilePackages`
- Library: TypeScript transpiled with tsup, StyleX calls shipped uncompiled, tokens via `defineVars` in a sibling package

Full configs and stack traces are in #1499 and #1505. Happy to put together a minimal public reproduction repo if that would help prioritize this.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.