Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions packages/@stylexjs/unplugin/__tests__/unplugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,228 @@ describe('@stylexjs/unplugin', () => {
expect(css).toContain('.x1aif7nf');
});
});

describe('pre-compiled StyleX package warning (issue #1207)', () => {
/**
* 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('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();
}
});
});

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('App Router');
} 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();
}
});
});
});
124 changes: 117 additions & 7 deletions packages/@stylexjs/unplugin/src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ 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;
}
Comment on lines +165 to +170

const { exports } = manifest;
if (exports && typeof exports === 'object') {
const conditions = [
exports['.'],
...Object.values(exports).filter(
(v) => v !== null && typeof v === 'object',
),
];
for (const cond of conditions) {
if (!cond || typeof cond !== 'object') continue;
if (
(typeof cond.style === 'string' && cond.style.endsWith('.css')) ||
(typeof cond.css === 'string' && cond.css.endsWith('.css'))
) {
return true;
}
}
}
Comment on lines +165 to +203

return false;
}

function mapToPackageInfos(map) {
return Array.from(map, ([name, precompiled]) => ({ name, precompiled }));
}

function discoverStylexPackages({
importSources,
explicitPackages,
Expand All @@ -174,12 +207,15 @@ function discoverStylexPackages({
.filter(Boolean)
.concat(['@stylexjs/stylex']),
);
const found = new Set(explicitPackages || []);

const found = new Map((explicitPackages || []).map((name) => [name, false]));

const pkgJsonPath = findNearestPackageJson(rootDir);
if (!pkgJsonPath) return Array.from(found);
if (!pkgJsonPath) return mapToPackageInfos(found);
const pkgDir = path.dirname(pkgJsonPath);
const pkgJson = readJSON(pkgJsonPath);
if (!pkgJson) return Array.from(found);
if (!pkgJson) return mapToPackageInfos(found);

const depFields = [
'dependencies',
'devDependencies',
Expand All @@ -198,14 +234,31 @@ function discoverStylexPackages({
manifestPath = resolver.resolve(`${dep}/package.json`, {
paths: [pkgDir],
});
} catch {}
} 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) continue;
const manifest = readJSON(manifestPath);
if (hasStylexDependency(manifest, targetPackages)) {
found.add(dep);
found.set(dep, hasPrecompiledCss(manifest));
}
}
return Array.from(found);

return mapToPackageInfos(found);
}

const JS_LIKE_RE = /\.[cm]?[jt]sx?(\?|$)/;
Expand Down Expand Up @@ -252,12 +305,69 @@ 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 packageList = precompiledPackages.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 <link> appears after your app <link> in <head>
(workaround only — this is fragile and not recommended long-term).
`);
}
Comment on lines +342 to +373

const isNextAppRouter =
!!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 && isNextAppRouter) {
const msg = `[StyleX] ❌ \`runtimeInjection\` must not be used with the Next.js App Router.

The App Router 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 <style> tags in browser DevTools.

Remove \`runtimeInjection: true\` from your StyleX plugin config.
`;
if (!dev) {
throw new Error(msg);
} else {
console.error(msg);
}
}

const stylexPackages = stylexPackageInfos.map((p) => p.name);

// Resolve nearest node_modules and cache under node_modules/.stylex/rules.json
function findNearestNodeModules(startDir) {
let dir = startDir;
Expand Down