From f18717a4b2cc545d1b655eb5555b1aeea2245ddd Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Tue, 6 Oct 2026 16:46:11 -0400 Subject: [PATCH] [unplugin] stop loading imports from transform In watch mode, transform awaited this.load() on every import of a StyleX file. That never finishes when two StyleX files import each other (vite build --watch, rollup -w), and on a cold start of the Vite dev server it can wait on the dependency optimizer, which is waiting on this transform (#1912). The walk also did nothing useful: it read meta.stylex, which transform never returned. transform now returns meta: { stylex: rules }, so shouldTransformCachedModule can restore the rules of a cached module. That hook now also skips runtimeInjection builds and ignores meta without a stylex array. --- .../unplugin/__tests__/unplugin.test.js | 60 +++++++++++++++++++ packages/@stylexjs/unplugin/src/core.js | 57 ++++++------------ 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/packages/@stylexjs/unplugin/__tests__/unplugin.test.js b/packages/@stylexjs/unplugin/__tests__/unplugin.test.js index a8c1ae090..f0cd26816 100644 --- a/packages/@stylexjs/unplugin/__tests__/unplugin.test.js +++ b/packages/@stylexjs/unplugin/__tests__/unplugin.test.js @@ -530,4 +530,64 @@ describe('@stylexjs/unplugin', () => { } }); }); + + // Regression: https://github.com/facebook/stylex/issues/1912 + describe('Rollup and Vite watch mode', () => { + const source = ` + import * as stylex from '@stylexjs/stylex'; + import { other } from './other.js'; + export const styles = stylex.create({ foo: { color: 'red' } }); + `; + + test('transform does not wait for its imports to load', async () => { + const plugin = unplugin.raw({ runtimeInjection: false }); + plugin.buildStart(); + // `./other.js` can't finish loading until this transform returns, like + // the other side of an import cycle in watch mode, or a prebundled + // dependency on a cold start of the Vite dev server. + const ctx = { + meta: { watchMode: true }, + parse: () => ({ + body: [ + { type: 'ImportDeclaration', source: { value: './other.js' } }, + ], + }), + resolve: async (spec) => ({ id: `/virtual/${spec}`, external: false }), + load: jest.fn(() => new Promise(() => {})), + }; + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve('timed out'), 1000); + }); + const result = await Promise.race([ + plugin.transform.call(ctx, source, '/virtual/watch.js'), + timeout, + ]); + clearTimeout(timer); + expect(result).not.toBe('timed out'); + expect(ctx.load).not.toHaveBeenCalled(); + }); + + test('restores the rules of a cached module from its meta', async () => { + const id = '/virtual/cached.js'; + const plugin = unplugin.raw({ runtimeInjection: false }); + plugin.buildStart(); + const { meta } = await plugin.transform(source, id); + + // A build that starts without these rules, for example from a Rollup + // cache saved by another process, skips the unchanged module and only + // gets its meta back. + const saved = globalThis.__stylex_unplugin_store; + globalThis.__stylex_unplugin_store = undefined; + try { + const next = unplugin.raw({ runtimeInjection: false }); + next.buildStart(); + expect(next.__stylexCollectCss()).toBe(''); + expect(next.shouldTransformCachedModule({ id, meta })).toBe(false); + expect(next.__stylexCollectCss()).toContain('color: red'); + } finally { + globalThis.__stylex_unplugin_store = saved; + } + }); + }); }); diff --git a/packages/@stylexjs/unplugin/src/core.js b/packages/@stylexjs/unplugin/src/core.js index ece615d60..a860efbb9 100644 --- a/packages/@stylexjs/unplugin/src/core.js +++ b/packages/@stylexjs/unplugin/src/core.js @@ -439,17 +439,15 @@ export const unpluginFactory = (userOptions = {}, metaOptions) => { const result = await runBabelTransform(code, file, '@stylexjs/unplugin'); const { metadata } = result; + const rules = + metadata && Array.isArray(metadata.stylex) ? metadata.stylex : []; if (!stylexOptions.runtimeInjection) { - const hasRules = - metadata && - Array.isArray(metadata.stylex) && - metadata.stylex.length > 0; const shared = getSharedStore(); - if (hasRules) { - stylexRulesById.set(id, metadata.stylex); - shared.rulesById.set(id, metadata.stylex); + if (rules.length > 0) { + stylexRulesById.set(id, rules); + shared.rulesById.set(id, rules); shared.version++; - await persistRulesToDisk(id, metadata.stylex); + await persistRulesToDisk(id, rules); } else { stylexRulesById.delete(id); if (shared.rulesById.has(id)) { @@ -460,40 +458,23 @@ export const unpluginFactory = (userOptions = {}, metaOptions) => { } } - // Rollup/Vite watch-mode support: collect stylex metadata from cached deps - // Only when running in rollup-like context - // $FlowExpectedError[incompatible-use] - const ctx = this; - if ( - ctx && - ctx.meta && - ctx.meta.watchMode && - typeof ctx.parse === 'function' - ) { - try { - const ast = ctx.parse(result.code); - for (const stmt of ast.body) { - if (stmt.type === 'ImportDeclaration') { - // $FlowExpectedError[incompatible-call] - const resolved = await ctx.resolve(stmt.source.value, id); - if (resolved && !resolved.external) { - // $FlowExpectedError[incompatible-call] - const loaded = await ctx.load(resolved); - if (loaded && loaded.meta && 'stylex' in loaded.meta) { - stylexRulesById.set(resolved.id, loaded.meta.stylex); - } - } - } - } - } catch {} - } - return { code: result.code, map: result.map }; + // Rollup keeps `meta` with a cached module, so when a watch rebuild skips + // an unchanged file, `shouldTransformCachedModule` below gets its rules + // back. Don't load imports from here: awaiting `this.load()` inside + // `transform` never finishes when two StyleX files import each other in + // watch mode, and on a cold start of the Vite dev server it can wait on + // the dependency optimizer, which is waiting on this transform (#1912). + return { code: result.code, map: result.map, meta: { stylex: rules } }; }, // Rollup: ensure cached modules still provide their metadata shouldTransformCachedModule({ id, meta }) { - if (meta && 'stylex' in meta) { - stylexRulesById.set(id, meta.stylex); + if (!stylexOptions.runtimeInjection && Array.isArray(meta?.stylex)) { + if (meta.stylex.length > 0) { + stylexRulesById.set(id, meta.stylex); + } else { + stylexRulesById.delete(id); + } } return false; },