Skip to content

feat(utils): shared Next.js StyleX adapter (webpack mode) - #1380

Merged
schickling-assistant merged 4 commits into
mainfrom
schickling-assistant/2026-09-24-stylex-next-adapter
Sep 24, 2026
Merged

schickling-assistant merged 4 commits into
mainfrom
schickling-assistant/2026-09-24-stylex-next-adapter

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

StyleX has a Vite path in @overeng/utils/node/stylex (createStylexVitePlugins) but nothing for Next.js. The one proven Next integration lives as app-local config in the schickling.dev blog worktree (apps/schickling.dev/stylex.babel.mjs + next.config.mjs + postcss.config.mjs): a webpack enforce: 'pre' babel-loader rule plus @stylexjs/postcss-plugin, hand-wired so the two passes share one Babel options object. Every future Next consumer would copy-paste that wiring with nothing keeping the two sides from drifting — and both of its silent failure modes (carrier import lost, @stylex; at-rule removed) ship an app with no StyleX CSS and a green build. The obvious alternatives are dead ends: @stylexjs/nextjs-plugin is npm-deprecated and frozen at StyleX 0.11.1, @stylexjs/unplugin publishes no ./next entry (its emission is written against Rollup's emitFile, which webpack does not provide), there is no @stylexjs/swc-plugin on npm, and a root babel.config.js switches the whole app off SWC and breaks next/font (measured: the build fails).

Goal

@overeng/utils/node/stylex/next exports createStylexNext: one factory, one shared StyleX options object, feeding both the webpack rule and the PostCSS plugin entry — with loud build failures when CSS emission/collection goes missing, proven by a fixture Next app build whose class names match the Vite/Babel path for the same token source.

Decisions

  • Checked JavaScript + structural types (next.js / next-types.d.ts), mirroring ./node/stylex: Next loads next.config.mjs / postcss.config.mjs through Node, which refuses TypeScript stripping for packages under node_modules (fix(stylex): make Vite preset Node-executable #1167), and naming Next/webpack types would publish this package's bundler major as part of its contract.
  • One shared options object by identity: adapter.webpackRule.use[0].options === adapter.postcssPlugin['@stylexjs/postcss-plugin'].babelConfig — asserted by identity in the unit tests. The class names in SSR markup and the selectors in emitted CSS are produced by the same options, so they cannot drift.
  • enableInlinedConditionalMerge: true on the shared options — not cargo-cult: it is what makes this transform's atomic class names identical to @stylexjs/unplugin's Vite path for the same source (verified by direct @stylexjs/babel-plugin experiments; the fixture build asserts the equality end-to-end).
  • Loud failures at two levels: config-time (carrier file missing, @stylex; at-rule absent, sourceDir absent, mdx in extensions) and build-time (a webpack guard on the client compilation pushes compilation.errors when the carrier is absent from the module graph or an unreplaced @stylex at-rule survives into an emitted CSS asset). The module-graph check is build-only — dev's lazy/HMR graphs can legitimately defer the carrier import; the residue check stays in both modes.
  • Collection and transformation share one validated extension set: the default includes ts,tsx,js,jsx,mjs,cjs,mts,cts; a custom list changes both the webpack test and collector globs. Unsupported suffixes (including mdx) are rejected.
  • The fixture installs its dependencies for real (pnpm, versions read from this package's installed graph so the genie catalog stays the SSOT). Symlinked node_modules pointing outside the app root make Next 16's cold build exit 1 with zero diagnostics — reproduced on a plain-CSS baseline with no StyleX and no webpack hook — while a real install cold-builds cleanly. The fixture type-checks with classic typescript@5.9.3 because Next's dependency check requires typescript/lib/typescript.js, which the catalog's native TS 7 package no longer ships.
  • Turbopack unsupported unless proven: Next 16 defaults dev and build to Turbopack, which never calls the webpack hook — stylex.* calls stay uncompiled and styling silently degrades to the runtime. Consumers pass --webpack; the adapter documents this at the entry.
  • One direct runtime dependency, postcss@8.5.26, parses actual at-rules in the carrier and emitted CSS rather than mistaking comments/strings for rules. The Babel loader and StyleX plugins still resolve from the consuming app. externalPackages is also returned as transpilePackages and checked against the Next webpack-hook config, so uncompiled TS/JSX cannot silently bypass Next transpilation. Next exact-pins its internal PostCSS 8.4.31; the two-version exception preserves that pin.

Verification

Review fixes were test-first; vitest run src/node/stylex/next.unit.test.ts failed 5/21 before the adapter changes:

  1. Extension parity: expected collector src/**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}, got a glob without mts,cts; custom extension test also required matching webpack behavior. After: both share one validated set.
  2. External TS/JSX: expected adapter.transpilePackages to equal ['@scope/pkg'], got undefined. After: that value is returned and the webpack hook rejects missing/mismatched Next config.transpilePackages.
  3. Synchronous child timeout: new focused build test failed before the timed runner (runFixtureCommand is not defined); after, a deliberately non-terminating child is killed at 100 ms and the thrown error names the timed-out fixture. Both fixture spawnSync calls have explicit child deadlines (90 s install, 180 s build).
  4. Residue: /* @stylex; */ .x { content: "@stylex;" } produced an erroneous build failure before; after, comments/strings are ignored and a nested real @stylex; is detected.
  5. Carrier: /* @stylex; */ was accepted before; after, a carrier needs an actual empty @stylex; PostCSS at-rule (not a comment, string or parameterized rule).

Post-rebase proof at head 058cdcefd9e6c2f8c97f72af5a3c70b62e8aa89f:

  • vitest run src/node/stylex/next.unit.test.ts src/node/stylex/next.build.test.ts --reporter=dot — 23/23 passed (21 unit + child-timeout and real Next 16 webpack/Vite CSS fixture), duration 12.16 s under a no-Corepack PATH with pinned PNPM_BIN.
  • Package TypeScript 7.0.2 compiler, packages/@overeng/utils/node_modules/.bin/tsc --noEmit -p packages/@overeng/utils/tsconfig.json: 8 diagnostics before (the externalPackages/extensions declarations were swallowed by unterminated JSDoc; one Error.code access was unguarded), 0 after. Restored both */ delimiters and narrowed result.error before reading code. The package compiler/config is the same TS7 source check underlying @overeng/utils:dist.
  • oxfmt --check and oxlint --deny-warnings on changed source/docs/catalog files — clean; bun genie/ci-scripts/native-dep-policy-audit.ts pnpm-lock.yaml — native dependency policy audit: OK.
  • Cold-HOME devenv tasks run genie:run --mode single and subsequent cold-HOME genie:check --mode single — passed, including after rebase; generated Buck lock projections refreshed.
  • CI run 36046778829 passed 16/18 required checks but Linux/macOS Buck unit tests uncovered a PR-caused fixture install failure (spawnSync corepack ENOENT): the contained action has no Corepack on PATH. Before: the focused real-build fixture failed under a no-Corepack PATH. After: the fixture requires an explicit attested PNPM_BIN backed by repo-pinned Nix pnpm 12.4.1, and its Next subprocess uses the declared NODE_BIN; the focused StyleX suite passed 23/23 with Corepack absent from PATH, and the actual local-only Buck //packages/@overeng/utils:test action passed 257/257 including the Next build (28 files). nix build --no-link .#buck2-capabilities and cold-HOME genie:check passed after regeneration; package TypeScript 7, scoped oxfmt/oxlint, and nixfmt --check flake.nix passed.
  • Previous CI run 36022987531 exposed two PR-caused defects: unclassified @next/swc-*/@img/sharp-* lockfile families and JSDoc inference widening webpackRule.enforce to string; the policy now classifies both pure prebuilt families, retains @oxlint/binding, and StylexWebpackRule explicitly types the rule. All 13 failed jobs had one of these two roots (the Nix iana-etc retry recovered).

devenv tasks run check:* was not run locally under dev3 load; CI owns the authoritative broad lanes.

Complexity

One checked-JS module + types + tests. The only added helper is a bounded fixture-child runner; PostCSS handles parsing without a custom CSS grammar.

Concerns

  • next (plus react/react-dom/stylex build plugins) enter utils' devDependencies; direct runtime postcss@8.5.26 adds parsing to the adapter. Next exact-pins internal PostCSS 8.4.31; the catalog exception records both versions rather than overriding Next.
  • The fixture build test runs in the standard test lane (~20s: registry install from the warm store + webpack build). It needs network for the fixture's pnpm install, and Buck supplies the repo-pinned pnpm through its attested PNPM_BIN capability rather than ambient Corepack; the store is pre-warmed by the repo install which carries the same versions.
  • The guard enforces carrier-in-graph on !isServer && nextRuntime === 'node' compilations only; an edge-runtime app fails loudly today rather than silently — revisit if an edge consumer appears.

Friction & bottlenecks

  • The megarepo store on /srv/bulk was pathologically slow during this work (a bare git worktree list took 74s; composed-workspace creation timed out twice at 300s). The worktree lives on the home device instead; store hygiene for it is a follow-up.
  • Cold devenv shell -- genie hit global core.hooksPath rejection; running devenv tasks run genie:run --mode single avoided shell-entry hooks. An initial Genie pass exposed the unacknowledged Next/PostCSS duplicate, which is now explicitly recorded.

Follow-ups

  • Migrate the schickling.dev blog app's stylex.babel.mjs wiring onto this adapter (separate change in that repo).
  • Stiftung website migration off @stylexswc/nextjs-plugin (separate change).
  • StyleX VRS decision record for the Next adapter choice lives in dotfiles context/stylex (peer work).

References

  • Reference implementation: schickling.dev worktree schickling-assistant/2026-09-22-2026-09-22-stylex-blog-chrome (apps/schickling.dev/stylex.babel.mjs, next.config.mjs, postcss.config.mjs)
  • fix(stylex): make Vite preset Node-executable #1167 (checked-JS under node_modules)
Posted on behalf of @schickling
field value
agent_identity dev3.direct.omp.mx94s4y2
session unknown
agent_persona generalist
agent_supervisor unavailable
agent_tool OMP
agent_tool_version 18.3.0
agent_runtime OMP 18.3.0
agent_model openai-codex/gpt-6-sol
worktree unknown
tooling_profile dotfiles@0d57ec1

@schickling-assistant schickling-assistant added origin:agent Filed or primarily produced by an AI agent · Set: AI agent or manual type:feature New user-visible or system capability · Set: manual area:tooling Developer tooling, scripts, and utilities · Set: manual labels Sep 24, 2026
@schickling-assistant
schickling-assistant marked this pull request as ready for review September 24, 2026 15:49
@github-actions
github-actions Bot requested a review from schickling September 24, 2026 15:49
`@overeng/utils/node/stylex/next` exports `createStylexNext`: one shared
StyleX babel options object feeds both a webpack `enforce: 'pre'`
babel-loader rule scoped to the configured source dirs (keeping SWC and
next/font intact — no root Babel config) and the `@stylexjs/postcss-plugin`
entry that collects into the carrier stylesheet. Missing carrier imports or
`@stylex` at-rules fail the build loudly at config time and on the client
compilation, instead of silently shipping without StyleX CSS.

`enableInlinedConditionalMerge` matches @stylexjs/unplugin's Vite path so
the atomic class names are identical across bundlers for the same source;
the fixture build test asserts that equality end-to-end for Next 15.5 and 16
(webpack mode; Turbopack unsupported).

agent-identity: dev3.direct.omp.c35amsc9
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.2.11
agent-runtime: OMP 18.2.11
agent-model: openai-codex/gpt-5.6-sol
tooling-profile: dotfiles@fa0992e
agent-identity: dev3.direct.omp.mx94s4y2
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.3.0
agent-runtime: OMP 18.3.0
agent-model: openai-codex/gpt-6-sol
tooling-profile: dotfiles@0d57ec1
@schickling-assistant
schickling-assistant force-pushed the schickling-assistant/2026-09-24-stylex-next-adapter branch from 452fe99 to 09b6454 Compare September 24, 2026 19:09
agent-identity: dev3.direct.omp.mx94s4y2
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.3.0
agent-runtime: OMP 18.3.0
agent-model: openai-codex/gpt-6-sol
tooling-profile: dotfiles@0d57ec1
agent-identity: dev3.direct.omp.mx94s4y2
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.3.0
agent-runtime: OMP 18.3.0
agent-model: openai-codex/gpt-6-sol
tooling-profile: dotfiles@0d57ec1
@schickling-assistant
schickling-assistant merged commit a00e1e0 into main Sep 24, 2026
28 checks passed
@schickling-assistant
schickling-assistant deleted the schickling-assistant/2026-09-24-stylex-next-adapter branch September 24, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:tooling Developer tooling, scripts, and utilities · Set: manual origin:agent Filed or primarily produced by an AI agent · Set: AI agent or manual type:feature New user-visible or system capability · Set: manual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant