diff --git a/packages/@stylexjs/unplugin/__tests__/unplugin.test.js b/packages/@stylexjs/unplugin/__tests__/unplugin.test.js index 4439619d2..724856917 100644 --- a/packages/@stylexjs/unplugin/__tests__/unplugin.test.js +++ b/packages/@stylexjs/unplugin/__tests__/unplugin.test.js @@ -331,4 +331,323 @@ describe('@stylexjs/unplugin', () => { expect(css).toContain('.x1aif7nf'); }); }); + + describe('pre-compiled StyleX package warning (issue #1207)', () => { + beforeEach(() => { + delete globalThis.__stylex_warned_packages; + }); + + /** + * Helper: creates a temporary directory that looks like a Next.js-style + * project consuming a StyleX-based design-system package. + * + * @param {object} depManifest - The package.json for the dependency. + * @returns {{ tempDir: string, cleanup: () => void, originalCwd: string }} + */ + function makeProjectWithDep(depManifest) { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'stylex-warn-test-'), + ); + const originalCwd = process.cwd(); + + const appPkg = { + dependencies: { [depManifest.name]: '1.0.0' }, + }; + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify(appPkg), + 'utf8', + ); + + const depDir = path.join(tempDir, 'node_modules', depManifest.name); + fs.mkdirSync(depDir, { recursive: true }); + fs.writeFileSync( + path.join(depDir, 'package.json'), + JSON.stringify(depManifest), + 'utf8', + ); + + process.chdir(tempDir); + return { + tempDir, + originalCwd, + cleanup() { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; + } + + test('warns when a StyleX dep ships CSS via top-level "style" field', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + style: 'dist/index.css', + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-design-system'); + expect(calls).toContain('pre-compiled CSS'); + expect(calls).toContain('transpilePackages'); + expect(calls).toContain('useCSSLayers'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('warns when a StyleX dep ships CSS via exports.style', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + exports: { + '.': { + style: 'dist/index.css', + import: 'dist/index.mjs', + }, + }, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-design-system'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('warns when a StyleX dep ships CSS via exports.css', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + exports: { + '.': { + css: 'dist/index.css', + import: 'dist/index.mjs', + }, + }, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-design-system'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('warns when a StyleX dep ships CSS via subpath export pointing to a .css string', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + exports: { + './styles.css': './dist/styles.css', + }, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-design-system'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('warns when a StyleX dep ships CSS via a deep nested conditional export', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + exports: { + '.': { + import: { + style: './dist/index.css', + }, + }, + }, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-design-system'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('does NOT warn for a source-only StyleX dep (no CSS field)', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-source-design-system', + version: '1.0.0', + // Has StyleX as a peer dep but ships no pre-compiled CSS + peerDependencies: { '@stylexjs/stylex': '^0.0.0' }, + main: 'src/index.js', + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + // The warning should not mention this package at all + expect(calls).not.toContain('my-source-design-system'); + expect(calls).not.toContain('pre-compiled CSS'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('does NOT warn for a dep that does not use StyleX at all', () => { + const { cleanup } = makeProjectWithDep({ + name: 'unrelated-package', + version: '1.0.0', + style: 'dist/index.css', // has a CSS file but no StyleX dep + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).not.toContain('unrelated-package'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('warns when a StyleX dep is only in explicitPackages (not in package.json dependencies)', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-explicit-only-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + style: 'dist/index.css', + }); + fs.writeFileSync( + path.join(process.cwd(), 'package.json'), + JSON.stringify({ dependencies: {} }), + 'utf8', + ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory( + { dev: false, externalPackages: ['my-explicit-only-design-system'] }, + { framework: 'rollup' }, + ); + const calls = warnSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('my-explicit-only-design-system'); + expect(calls).toContain('pre-compiled CSS'); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + + test('only warns once per package per process (deduplication)', () => { + const { cleanup } = makeProjectWithDep({ + name: 'my-design-system', + version: '1.0.0', + dependencies: { '@stylexjs/stylex': '^0.0.0' }, + style: 'dist/index.css', + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + unpluginFactory({ dev: false }, { framework: 'rollup' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + + unpluginFactory({ dev: false }, { framework: 'rollup' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + cleanup(); + } + }); + }); + + describe('runtimeInjection + Next.js App Router guard (issue #1207)', () => { + const NEXT_ENV_VARS = ['NEXT_RUNTIME', 'NEXT_PHASE']; + + afterEach(() => { + // Clean up any env vars set during tests + for (const v of NEXT_ENV_VARS) { + delete process.env[v]; + } + }); + + test('console.error in dev when runtimeInjection is used with NEXT_RUNTIME', () => { + process.env.NEXT_RUNTIME = 'nodejs'; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + unpluginFactory( + { dev: true, runtimeInjection: true }, + { framework: 'webpack' }, + ); + const calls = errorSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('runtimeInjection'); + expect(calls).toContain('Next.js'); + } finally { + errorSpy.mockRestore(); + } + }); + + test('throws in production when runtimeInjection is used with NEXT_RUNTIME', () => { + process.env.NEXT_RUNTIME = 'nodejs'; + expect(() => { + unpluginFactory( + { dev: false, runtimeInjection: true }, + { framework: 'webpack' }, + ); + }).toThrow(/runtimeInjection/); + }); + + test('does NOT throw or error when runtimeInjection is false in Next.js', () => { + process.env.NEXT_RUNTIME = 'nodejs'; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + expect(() => { + unpluginFactory( + { dev: false, runtimeInjection: false }, + { framework: 'webpack' }, + ); + }).not.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + test('does NOT throw or error when runtimeInjection is true in non-Next.js env', () => { + // Make sure no Next.js env vars are set + for (const v of NEXT_ENV_VARS) delete process.env[v]; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + expect(() => { + unpluginFactory( + { dev: false, runtimeInjection: true }, + { framework: 'rollup' }, + ); + }).not.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + }); }); diff --git a/packages/@stylexjs/unplugin/src/core.js b/packages/@stylexjs/unplugin/src/core.js index 340af13cf..e381fbb85 100644 --- a/packages/@stylexjs/unplugin/src/core.js +++ b/packages/@stylexjs/unplugin/src/core.js @@ -162,6 +162,53 @@ function hasStylexDependency(manifest, targetPackages) { return false; } +function hasPrecompiledCss(manifest) { + if (!manifest || typeof manifest !== 'object') return false; + + if (typeof manifest.style === 'string' && manifest.style.endsWith('.css')) { + return true; + } + + const { exports } = manifest; + if (exports === undefined) { + return false; + } + + const visited = new Set(); + function scan(val) { + if (val === null || val === undefined) return false; + if (typeof val === 'string') { + return val.endsWith('.css'); + } + if (typeof val === 'object') { + if (visited.has(val)) return false; + visited.add(val); + if (Array.isArray(val)) { + for (const item of val) { + if (scan(item)) return true; + } + return false; + } + if ( + (typeof val.style === 'string' && val.style.endsWith('.css')) || + (typeof val.css === 'string' && val.css.endsWith('.css')) + ) { + return true; + } + for (const key of Object.keys(val)) { + if (scan(val[key])) return true; + } + } + return false; + } + + return scan(exports); +} + +function mapToPackageInfos(map) { + return Array.from(map, ([name, precompiled]) => ({ name, precompiled })); +} + function discoverStylexPackages({ importSources, explicitPackages, @@ -174,38 +221,67 @@ function discoverStylexPackages({ .filter(Boolean) .concat(['@stylexjs/stylex']), ); - const found = new Set(explicitPackages || []); + + const found = new Map(); + const explicitSet = new Set(explicitPackages || []); + const deps = new Set(explicitPackages || []); + const pkgJsonPath = findNearestPackageJson(rootDir); - if (!pkgJsonPath) return Array.from(found); - const pkgDir = path.dirname(pkgJsonPath); - const pkgJson = readJSON(pkgJsonPath); - if (!pkgJson) return Array.from(found); - const depFields = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - ]; - const deps = new Set(); - for (const field of depFields) { - const entries = pkgJson[field]; - if (!entries || typeof entries !== 'object') continue; - for (const name of Object.keys(entries)) deps.add(name); + const pkgDir = pkgJsonPath ? path.dirname(pkgJsonPath) : rootDir; + if (pkgJsonPath) { + const pkgJson = readJSON(pkgJsonPath); + if (pkgJson) { + const depFields = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]; + for (const field of depFields) { + const entries = pkgJson[field]; + if (!entries || typeof entries !== 'object') continue; + for (const name of Object.keys(entries)) deps.add(name); + } + } } + for (const dep of deps) { let manifestPath = null; try { manifestPath = resolver.resolve(`${dep}/package.json`, { paths: [pkgDir], }); - } catch {} - if (!manifestPath) continue; + } catch { + try { + const entry = resolver.resolve(dep, { paths: [pkgDir] }); + manifestPath = findNearestPackageJson(path.dirname(entry)); + } catch {} + + if (!manifestPath) { + const candidate = path.join( + pkgDir, + 'node_modules', + dep, + 'package.json', + ); + if (fs.existsSync(candidate)) manifestPath = candidate; + } + } + + if (!manifestPath) { + if (explicitSet.has(dep)) { + found.set(dep, false); + } + continue; + } + const manifest = readJSON(manifestPath); - if (hasStylexDependency(manifest, targetPackages)) { - found.add(dep); + if (explicitSet.has(dep) || hasStylexDependency(manifest, targetPackages)) { + found.set(dep, hasPrecompiledCss(manifest)); } } - return Array.from(found); + + return mapToPackageInfos(found); } const JS_LIKE_RE = /\.[cm]?[jt]sx?(\?|$)/; @@ -252,12 +328,78 @@ export const unpluginFactory = (userOptions = {}, metaOptions) => { const requireFromCwd = nearestPkgJson ? createRequire(nearestPkgJson) : createRequire(path.join(process.cwd(), 'package.json')); - const stylexPackages = discoverStylexPackages({ + const stylexPackageInfos = discoverStylexPackages({ importSources, explicitPackages: externalPackages, rootDir: nearestPkgJson ? path.dirname(nearestPkgJson) : process.cwd(), resolver: requireFromCwd, }); + + const precompiledPackages = stylexPackageInfos + .filter((p) => p.precompiled) + .map((p) => p.name); + + if (precompiledPackages.length > 0) { + const warnedSet = (globalThis.__stylex_warned_packages = + globalThis.__stylex_warned_packages || new Set()); + const newWarnPackages = precompiledPackages.filter( + (name) => !warnedSet.has(name), + ); + + if (newWarnPackages.length > 0) { + newWarnPackages.forEach((name) => warnedSet.add(name)); + const packageList = newWarnPackages.map((p) => ` • ${p}`).join('\n'); + console.warn(` +[StyleX] ⚠️ Potential CSS ordering issue detected. + +The following packages use StyleX and ship pre-compiled CSS: +${packageList} + +Because these packages are not being re-compiled by the StyleX plugin, both +the library CSS and your app-local CSS are emitted as separate files. They +share the same atomic class names (e.g. .xuxw1ft for white-space:nowrap) but +your app's CSS is loaded last and therefore unconditionally overrides the +library's CSS for any colliding rule — regardless of intended priority. + +Recommended fixes (pick one): + 1. Publish the library without pre-compiled CSS and add it to Next.js + \`transpilePackages\` so a single compiler handles everything. + 2. Enable \`useCSSLayers: true\` in BOTH the library build and this plugin + so @layer order governs priority instead of stylesheet load order. + 3. Ensure the library appears after your app in + (workaround only — this is fragile and not recommended long-term). +`); + } + } + + const isNext = + !!process.env.NEXT_RUNTIME || + !!process.env.NEXT_PHASE || + (framework === 'webpack' && + !!( + process.env.npm_package_dependencies_next || + process.env.npm_package_devDependencies_next + )); + + if (userOptions.runtimeInjection && isNext) { + const msg = `[StyleX] ❌ \`runtimeInjection\` must not be used in Next.js. + +Next.js renders on the server and hydrates on the client. Styles +injected at runtime are appended after the static stylesheet, so they always +override static rules — including styles from third-party StyleX libraries. +They also appear as empty