diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js
index d5753ae0..33193539 100644
--- a/apps/docs/.eslintrc.js
+++ b/apps/docs/.eslintrc.js
@@ -13,6 +13,7 @@ module.exports = {
// The Eslint rule still needs work, but you can
// enable it to test things out.
'@stylexjs/valid-styles': 'error',
+ // '@stylexjs/no-unused': 'warn',
// 'ft-flow/space-after-type-colon': 0,
// 'ft-flow/no-types-missing-file-annotation': 0,
// 'ft-flow/generic-spacing': 0,
diff --git a/apps/docs/package.json b/apps/docs/package.json
index d1b0ccd4..d02499cf 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@babel/eslint-parser": "^7.25.8",
+ "@stylexjs/eslint-plugin": "0.8.0",
"@stylexjs/babel-plugin": "0.9.3",
"clean-css": "^5.3.2",
"eslint": "^8.57.1",
diff --git a/package-lock.json b/package-lock.json
index 5c98b847..a49a0dd8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -116,6 +116,7 @@
"devDependencies": {
"@babel/eslint-parser": "^7.25.8",
"@stylexjs/babel-plugin": "0.9.3",
+ "@stylexjs/eslint-plugin": "0.8.0",
"clean-css": "^5.3.2",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
@@ -140,6 +141,17 @@
"node": ">=6.9.0"
}
},
+ "apps/docs/node_modules/@stylexjs/eslint-plugin": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@stylexjs/eslint-plugin/-/eslint-plugin-0.8.0.tgz",
+ "integrity": "sha512-1o626c96axO8nWpsY5PYV9CI+12Hr+cD4R4iYGlPRCHqbT0G4lgwBQorrJvb/ChK9Y/u5c7VZq2q2O5/G9C1AA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-shorthand-expand": "^1.2.0",
+ "micromatch": "^4.0.5"
+ }
+ },
"apps/docs/node_modules/array.prototype.findlastindex": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz",
@@ -34940,6 +34952,7 @@
"@docusaurus/preset-classic": "2.4.1",
"@mdx-js/react": "^1.6.22",
"@stylexjs/babel-plugin": "0.9.3",
+ "@stylexjs/eslint-plugin": "0.8.0",
"@stylexjs/stylex": "0.9.3",
"@vercel/analytics": "^1.1.1",
"clean-css": "^5.3.2",
@@ -34967,6 +34980,16 @@
"regenerator-runtime": "^0.14.0"
}
},
+ "@stylexjs/eslint-plugin": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@stylexjs/eslint-plugin/-/eslint-plugin-0.8.0.tgz",
+ "integrity": "sha512-1o626c96axO8nWpsY5PYV9CI+12Hr+cD4R4iYGlPRCHqbT0G4lgwBQorrJvb/ChK9Y/u5c7VZq2q2O5/G9C1AA==",
+ "dev": true,
+ "requires": {
+ "css-shorthand-expand": "^1.2.0",
+ "micromatch": "^4.0.5"
+ }
+ },
"array.prototype.findlastindex": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz",
diff --git a/packages/eslint-plugin/__tests__/stylex-no-unused-test.js b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js
new file mode 100644
index 00000000..ea12998c
--- /dev/null
+++ b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js
@@ -0,0 +1,365 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ *
+ */
+
+'use strict';
+
+jest.disableAutomock();
+
+const { RuleTester: ESLintTester } = require('eslint');
+const rule = require('../src/stylex-no-unused');
+
+const eslintTester = new ESLintTester({
+ parser: require.resolve('hermes-eslint'),
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ },
+});
+
+eslintTester.run('stylex-no-unused', rule.default, {
+ valid: [
+ {
+ // all style used; identifier and literal
+ code: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ main: {
+ borderColor: {
+ default: 'green',
+ ':hover': 'red',
+ '@media (min-width: 1540px)': 1366,
+ },
+ borderRadius: 10,
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ const sizeStyles = stylex.create({
+ [8]: {
+ height: 8,
+ width: 8,
+ },
+ [10]: {
+ height: 10,
+ width: 10,
+ },
+ [12]: {
+ height: 12,
+ width: 12,
+ },
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }
+ `,
+ },
+ {
+ // stylex not default export
+ code: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ main: {
+ borderColor: {
+ default: 'green',
+ ':hover': 'red',
+ '@media (min-width: 1540px)': 1366,
+ },
+ borderRadius: 10,
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export const sizeStyles = stylex.create({
+ [8]: {
+ height: 8,
+ width: 8,
+ },
+ [10]: {
+ height: 10,
+ width: 10,
+ },
+ [12]: {
+ height: 12,
+ width: 12,
+ },
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }
+ `,
+ },
+ {
+ // indirect usage of style
+ code: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ main: {
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ const sizeStyles = stylex.create({
+ [8]: {
+ height: 8,
+ width: 8,
+ },
+ [10]: {
+ height: 10,
+ width: 10,
+ },
+ [12]: {
+ height: 12,
+ width: 12,
+ },
+ });
+ const widthStyles = stylex.create({
+ widthModeConstrained: {
+ width: 'auto',
+ },
+ widthModeFlexible: {
+ width: '100%',
+ },
+ })
+ // style used as export
+ function getWidthStyles() {
+ return widthStyles;
+ }
+ export default function TestComponent({ width: number}) {
+ // style used as variable
+ const red = styles.dynamic('red');
+ const display = width > 10 ? sizeStyles[12] : sizeStyles[8]
+ return(
+
+
+ )
+ }
+ `,
+ },
+ {
+ // styles default export
+ code: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ main: {
+ borderColor: {
+ default: 'green',
+ ':hover': 'red',
+ '@media (min-width: 1540px)': 1366,
+ },
+ borderRadius: 10,
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default styles;
+ `,
+ },
+ {
+ // styles named default inline export
+ code: `
+ import stylex from 'stylex';
+ export default styles = stylex.create({
+ maxDimensionsModal: {
+ maxWidth: '90%',
+ maxHeight: '90%',
+ },
+ halfWindowWidth: {
+ width: '50vw',
+ },
+ })
+ `,
+ },
+ {
+ // styles anonymous default inline export
+ code: `
+ import stylex from 'stylex';
+ export default stylex.create({
+ maxDimensionsModal: {
+ maxWidth: '90%',
+ maxHeight: '90%',
+ },
+ halfWindowWidth: {
+ width: '50vw',
+ },
+ })
+ `,
+ },
+ ],
+ invalid: [
+ {
+ code: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ main: {
+ borderColor: {
+ default: 'green',
+ ':hover': 'red',
+ '@media (min-width: 1540px)': 1366,
+ },
+ borderRadius: 10,
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }
+ `,
+ output: `
+ import stylex from 'stylex';
+ const styles = stylex.create({
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }
+ `,
+ errors: [
+ {
+ message: 'Unused style detected: styles.main',
+ },
+ ],
+ },
+ {
+ // Import form: import * as stylex from '@stylexjs/stylex';
+ code: `
+ import * as customStylex from '@stylexjs/stylex';
+ const styles = customStylex.create({
+ main: {
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ output: `
+ import * as customStylex from '@stylexjs/stylex';
+ const styles = customStylex.create({
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ errors: [
+ {
+ message: 'Unused style detected: styles.main',
+ },
+ ],
+ },
+ {
+ // Import form: import {create} from '@stylexjs/stylex';
+ code: `
+ import {create, attrs} from '@stylexjs/stylex';
+ const styles = create({
+ main: {
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ output: `
+ import {create, attrs} from '@stylexjs/stylex';
+ const styles = create({
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ errors: [
+ {
+ message: 'Unused style detected: styles.main',
+ },
+ ],
+ },
+ {
+ // Import form: import {create as c} from '@stylexjs/stylex';
+ code: `
+ import {create as c} from '@stylexjs/stylex';
+ const styles = c({
+ main: {
+ display: 'flex',
+ },
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ output: `
+ import {create as c} from '@stylexjs/stylex';
+ const styles = c({
+ dynamic: (color) => ({
+ backgroundColor: color,
+ })
+ });
+ export default function TestComponent() {
+ return(
+
+
+ )
+ }`,
+ errors: [
+ {
+ message: 'Unused style detected: styles.main',
+ },
+ ],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/src/index.js b/packages/eslint-plugin/src/index.js
index 07b55fc2..d96e7182 100644
--- a/packages/eslint-plugin/src/index.js
+++ b/packages/eslint-plugin/src/index.js
@@ -10,15 +10,18 @@
import validStyles from './stylex-valid-styles';
import sortKeys from './stylex-sort-keys';
import validShorthands from './stylex-valid-shorthands';
+import noUnused from './stylex-no-unused';
const rules: {
'valid-styles': typeof validStyles,
'sort-keys': typeof sortKeys,
'valid-shorthands': typeof validShorthands,
+ 'no-unused': typeof noUnused,
} = {
'valid-styles': validStyles,
'sort-keys': sortKeys,
'valid-shorthands': validShorthands,
+ 'no-unused': noUnused,
};
export { rules };
diff --git a/packages/eslint-plugin/src/stylex-no-unused.js b/packages/eslint-plugin/src/stylex-no-unused.js
new file mode 100644
index 00000000..4885ea2a
--- /dev/null
+++ b/packages/eslint-plugin/src/stylex-no-unused.js
@@ -0,0 +1,287 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict
+ */
+
+'use strict';
+
+/*:: import { Rule } from 'eslint'; */
+import type {
+ CallExpression,
+ Expression,
+ Node,
+ Program,
+ Property,
+ SpreadElement,
+ RestElement,
+ MemberExpression,
+ AssignmentProperty,
+ ExportDefaultDeclaration,
+ ExportNamedDeclaration,
+ ReturnStatement,
+} from 'estree';
+
+type PropertyValue =
+ | Property
+ | SpreadElement
+ | AssignmentProperty
+ | RestElement;
+
+function getPropertiesByName(node: Node | null) {
+ const properties = new Map();
+ if (node == null) {
+ return properties;
+ }
+ node.properties
+ ?.filter((property) => !property.computed && !property.method)
+ .forEach((property) => {
+ const { key } = property;
+ if (key?.type === 'Identifier') {
+ properties.set(key.name, property);
+ } else if (key?.type === 'Literal') {
+ properties.set(key.value, property);
+ }
+ });
+ return properties;
+}
+
+const stylexNoUnused = {
+ meta: {
+ fixable: 'code',
+ },
+ create(context: Rule.RuleContext): { ... } {
+ const stylexProperties = new Map>();
+ let stylexImportObject = 'stylex';
+ let stylexImportProperty = 'create';
+
+ function isStylexCreate(node: Node) {
+ return (
+ // const styles = s.create({...}) OR const styles = stylex.create({...})
+ (stylexImportObject !== '' &&
+ node.type === 'MemberExpression' &&
+ node.object.type === 'Identifier' &&
+ node.object.name === stylexImportObject &&
+ node.property.type === 'Identifier' &&
+ node.property.name === stylexImportProperty) ||
+ // const styles = c({...}) OR const styles = create({...})
+ (stylexImportObject === '' &&
+ node.type === 'Identifier' &&
+ node.name === stylexImportProperty)
+ );
+ }
+
+ function isStylexDeclaration(node: Node) {
+ return (
+ node &&
+ node.type === 'CallExpression' &&
+ isStylexCreate(node.callee) &&
+ node.arguments.length === 1 &&
+ node.arguments[0].type === 'ObjectExpression'
+ );
+ }
+
+ function saveStylexCalls(node: Node) {
+ const id = node.id;
+ const init = node.init;
+ if (id && id.type === 'Identifier' && init && isStylexDeclaration(init)) {
+ stylexProperties.set(
+ id.name,
+ getPropertiesByName(
+ init.arguments && init.arguments?.length > 0
+ ? init.arguments[0]
+ : null,
+ ),
+ );
+ }
+ }
+ function checkArguments(
+ namespaces: Map,
+ ): (argument: Expression | SpreadElement | null) => void {
+ return function (argument: Expression | SpreadElement | null): void {
+ if (argument) {
+ if (argument.type === 'Literal') {
+ namespaces.delete(argument.value);
+ } else if (argument.type === 'ObjectExpression') {
+ argument.properties.forEach((property) => {
+ if (property.key) {
+ namespaces.delete(property.key.name);
+ }
+ });
+ } else if (argument.type === 'ArrayExpression') {
+ argument.elements.forEach((element) => {
+ namespaces.delete(element?.value);
+ });
+ } else if (argument.type === 'ConditionalExpression') {
+ const { consequent, alternate } = argument;
+ // check for nested expressions
+ checkArguments(namespaces)(consequent);
+ checkArguments(namespaces)(alternate);
+ } else if (
+ argument.type === 'LogicalExpression' &&
+ argument.operator === '&&'
+ ) {
+ // check for nested expressions but only on the right
+ checkArguments(namespaces)(argument.right);
+ }
+ }
+ };
+ }
+
+ function parseStylexImportStyle(node: Node) {
+ // identify stylex import
+ if (
+ node.source?.value === '@stylexjs/stylex' &&
+ // $FlowFixMe[prop-missing]
+ node.importKind === 'value' &&
+ node.specifiers &&
+ node.specifiers.length > 0
+ ) {
+ // extract stylex import pattern
+ node.specifiers.forEach((specifier) => {
+ const specifierType = specifier.type;
+ if (specifierType === 'ImportNamespaceSpecifier') {
+ // import * as stylex from '@stylexjs/stylex';
+ stylexImportObject = specifier.local.name;
+ stylexImportProperty = 'create';
+ } else if (specifierType === 'ImportDefaultSpecifier') {
+ if (specifier.local.name === 'stylex') {
+ // import stylex from '@stylexjs/stylex';
+ stylexImportObject = 'stylex';
+ stylexImportProperty = 'create';
+ }
+ } else if (specifierType === 'ImportSpecifier') {
+ if (specifier.imported?.name === 'create') {
+ // import {create} from '@stylexjs/stylex' OR import {create as c} from '@stylexjs/stylex'
+ stylexImportObject = '';
+ stylexImportProperty = specifier.local.name;
+ }
+ }
+ });
+ }
+ }
+
+ return {
+ Program(node: Program) {
+ // detect stylex import style, which then decides which variables are stylex styles
+ node.body
+ .map((node) => (node.type === 'ImportDeclaration' ? node : null))
+ .filter(Boolean)
+ .forEach(parseStylexImportStyle);
+ // stylex.create can only be singular variable declarations at the root
+ // of the file so we can look directly on Program and populate our set.
+ node.body
+ .filter(({ type }) => type === 'VariableDeclaration')
+ .map(({ declarations }) =>
+ declarations && declarations.length === 1 ? declarations[0] : null,
+ )
+ .filter(Boolean)
+ .filter(({ init }) => init && isStylexDeclaration(init))
+ .forEach(saveStylexCalls);
+ },
+
+ // Exempt used styles: "stylex.__" or "styles[__]"
+ MemberExpression(node: MemberExpression) {
+ if (
+ node.object.type === 'Identifier' &&
+ stylexProperties.has(node.object.name)
+ ) {
+ if (node.computed && node.property.type !== 'Literal') {
+ stylexProperties.delete(node.object.name);
+ } else if (node.property.type === 'Identifier') {
+ stylexProperties.get(node.object.name)?.delete(node.property.name);
+ } else if (node.property.type === 'Literal') {
+ stylexProperties.get(node.object.name)?.delete(node.property.value);
+ }
+ }
+ },
+ // catch function call "functionName(param)"
+ CallExpression(node: CallExpression) {
+ const functionName = node.callee?.name;
+ if (functionName == null || !stylexProperties.has(functionName)) {
+ return;
+ }
+ const namespaces = stylexProperties.get(functionName);
+ if (namespaces == null) {
+ return;
+ }
+ node.arguments?.forEach(checkArguments(namespaces));
+ },
+
+ // Exempt used styles: export const exportStyles = stylex.create({});
+ ExportNamedDeclaration(node: ExportNamedDeclaration) {
+ const declarations = node.declaration?.declarations;
+ if (declarations?.length !== 1) {
+ return;
+ }
+ const exportName = declarations[0].id.name;
+ if (exportName == null || !stylexProperties.has(exportName)) {
+ return;
+ }
+ stylexProperties.delete(exportName);
+ },
+
+ // Exempt used styles: export default exportStyles;
+ ExportDefaultDeclaration(node: ExportDefaultDeclaration) {
+ const exportName = node.declaration.name;
+ if (exportName == null || !stylexProperties.has(exportName)) {
+ return;
+ }
+ stylexProperties.delete(exportName);
+ },
+
+ // Exempt used styles: used as return
+ ReturnStatement(node: ReturnStatement) {
+ if (node.argument?.type === 'Identifier') {
+ const returnName = node.argument.name;
+ if (stylexProperties.has(node.argument.name)) {
+ stylexProperties.delete(returnName);
+ }
+ }
+ },
+
+ 'Program:exit'() {
+ // Fallback to legacy `getSourceCode()` for compatibility with older ESLint versions
+ const sourceCode =
+ context.sourceCode ||
+ (typeof context.getSourceCode === 'function'
+ ? context.getSourceCode()
+ : null);
+
+ stylexProperties.forEach((namespaces, varName) => {
+ namespaces.forEach((node, namespaceName) => {
+ context.report({
+ node,
+ message: `Unused style detected: ${varName}.${namespaceName}`,
+ fix(fixer) {
+ const commaOffset =
+ sourceCode.getTokenAfter(node, {
+ includeComments: false,
+ })?.value === ','
+ ? 1
+ : 0;
+ const left = sourceCode.getTokenBefore(node, {
+ includeComments: false,
+ });
+ if (node.range == null || left?.range == null) {
+ return null;
+ }
+ return fixer.removeRange([
+ left.range[1],
+ node.range[1] + commaOffset,
+ ]);
+ },
+ });
+ });
+ });
+
+ stylexProperties.clear();
+ },
+ };
+ },
+};
+
+export default stylexNoUnused as typeof stylexNoUnused;