Skip to content

Commit e7233f7

Browse files
feat(eslint): add initial no-default-export-components rule
1 parent 1bbce7f commit e7233f7

File tree

6 files changed

+367
-0
lines changed

6 files changed

+367
-0
lines changed

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ export default typescript.config([
595595
'@typescript-eslint/prefer-promise-reject-errors': 'error',
596596
'@typescript-eslint/require-await': 'error',
597597
'@typescript-eslint/no-meaningless-void-operator': 'error',
598+
'@sentry/no-default-export-components': 'error',
598599
'@sentry/no-unnecessary-type-annotation': 'error',
599600
}
600601
: {},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import {noDefaultExportComponents} from './no-default-export-components';
12
import {noStaticTranslations} from './no-static-translations';
23
import {noUnnecessaryTypeAnnotation} from './no-unnecessary-type-annotation';
34

45
export const rules = {
6+
'no-default-export-components': noDefaultExportComponents,
57
'no-static-translations': noStaticTranslations,
68
'no-unnecessary-type-annotation': noUnnecessaryTypeAnnotation,
79
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {RuleTester} from '@typescript-eslint/rule-tester';
2+
3+
import {noDefaultExportComponents} from './no-default-export-components';
4+
5+
const ruleTester = new RuleTester({
6+
languageOptions: {
7+
parserOptions: {
8+
projectService: {
9+
allowDefaultProject: ['*.ts', '*.tsx', 'static/app/*.ts', 'static/app/*.tsx'],
10+
},
11+
tsconfigRootDir: __dirname,
12+
},
13+
},
14+
});
15+
16+
ruleTester.run('no-default-export-components', noDefaultExportComponents, {
17+
valid: [
18+
{
19+
code: `export function MyComponent() { return <div />; }`,
20+
filename: 'valid.tsx',
21+
},
22+
{
23+
code: `export const MyComponent = () => <div />;`,
24+
filename: 'valid.tsx',
25+
},
26+
{
27+
code: `export const util = () => null;`,
28+
filename: 'valid.tsx',
29+
},
30+
],
31+
invalid: [
32+
{
33+
code: 'function MyComponent() { return <div />; }\nexport default MyComponent;',
34+
output: 'export function MyComponent() { return <div />; }\n',
35+
errors: [{messageId: 'forbidden'}],
36+
filename: 'invalid.tsx',
37+
},
38+
{
39+
code: `const Panel = styled('div')\`padding: 0;\`;
40+
export default Panel;`,
41+
output: `export const Panel = styled('div')\`padding: 0;\`;
42+
`,
43+
errors: [{messageId: 'forbidden'}],
44+
filename: 'invalid.tsx',
45+
},
46+
],
47+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {AST_NODE_TYPES, ESLintUtils, type TSESTree} from '@typescript-eslint/utils';
2+
import {getParserServices} from '@typescript-eslint/utils/eslint-utils';
3+
import ts from 'typescript';
4+
5+
import {isReactComponentLike} from './utils/isReactComponentLike';
6+
import {lazy} from './utils/lazy';
7+
8+
function unwrapParenthesized(node: ts.Node): ts.Node {
9+
return ts.isParenthesizedExpression(node) ? unwrapParenthesized(node.expression) : node;
10+
}
11+
12+
function collectResolvedImportFiles(program: ts.Program) {
13+
const allowedFiles = new Set<string>();
14+
const compilerOptions = program.getCompilerOptions();
15+
16+
function addResolvedModuleFromImportCall(
17+
callExpr: ts.CallExpression,
18+
sourceFile: ts.SourceFile
19+
) {
20+
if (
21+
callExpr.expression.kind !== ts.SyntaxKind.ImportKeyword ||
22+
callExpr.arguments.length !== 1
23+
) {
24+
return;
25+
}
26+
const argument = callExpr.arguments[0]!;
27+
if (ts.isStringLiteralLike(argument)) {
28+
const resolved = ts.resolveModuleName(
29+
argument.text,
30+
sourceFile.fileName,
31+
compilerOptions,
32+
ts.sys
33+
);
34+
if (resolved.resolvedModule?.resolvedFileName) {
35+
allowedFiles.add(resolved.resolvedModule.resolvedFileName);
36+
}
37+
}
38+
}
39+
40+
function visit(node: ts.Node, sourceFile: ts.SourceFile): void {
41+
if (ts.isArrowFunction(node)) {
42+
const body = unwrapParenthesized(node.body);
43+
if (ts.isCallExpression(body)) {
44+
addResolvedModuleFromImportCall(body, sourceFile);
45+
}
46+
}
47+
48+
if (ts.isAwaitExpression(node)) {
49+
const expr = unwrapParenthesized(node.expression);
50+
if (ts.isCallExpression(expr)) {
51+
addResolvedModuleFromImportCall(expr, sourceFile);
52+
}
53+
}
54+
55+
ts.forEachChild(node, child => visit(child, sourceFile));
56+
}
57+
58+
for (const sourceFile of program.getSourceFiles()) {
59+
if (!sourceFile.isDeclarationFile) {
60+
ts.forEachChild(sourceFile, child => visit(child, sourceFile));
61+
}
62+
}
63+
64+
return allowedFiles;
65+
}
66+
67+
function findTopLevelFunctionDeclaration(
68+
body: TSESTree.ProgramStatement[],
69+
name: string
70+
) {
71+
return body.find(
72+
statement =>
73+
statement.type === AST_NODE_TYPES.FunctionDeclaration && statement.id?.name === name
74+
);
75+
}
76+
77+
function findTopLevelVariableDeclaration(
78+
body: TSESTree.ProgramStatement[],
79+
name: string
80+
): TSESTree.VariableDeclaration | undefined {
81+
return body.find((statement): statement is TSESTree.VariableDeclaration => {
82+
if (statement.type !== AST_NODE_TYPES.VariableDeclaration) {
83+
return false;
84+
}
85+
return statement.declarations.some(
86+
decl => decl.id.type === AST_NODE_TYPES.Identifier && decl.id.name === name
87+
);
88+
});
89+
}
90+
91+
const allowedFilesLazy = lazy(collectResolvedImportFiles);
92+
93+
export const noDefaultExportComponents = ESLintUtils.RuleCreator.withoutDocs({
94+
meta: {
95+
type: 'problem',
96+
docs: {
97+
description:
98+
'Disallow default exports of React components that are not lazy-imported',
99+
},
100+
fixable: 'code',
101+
schema: [],
102+
messages: {
103+
forbidden:
104+
'We prefer named exports. Default exports are not allowed unless this file is lazy-imported.',
105+
},
106+
},
107+
108+
create(context) {
109+
const parserServices = getParserServices(context);
110+
const allowedFiles = allowedFilesLazy(parserServices.program);
111+
const currentFileName = ts.sys.resolvePath(context.filename);
112+
113+
if (
114+
allowedFiles.has(currentFileName) ||
115+
// TODO: Eventually, it'd be nice to fully ban all default exports of components...
116+
context.sourceCode.ast.body.some(
117+
statement => statement.type === AST_NODE_TYPES.ExportNamedDeclaration
118+
)
119+
) {
120+
return {};
121+
}
122+
123+
return {
124+
ExportDefaultDeclaration(node) {
125+
if (
126+
node.declaration.type === AST_NODE_TYPES.ClassDeclaration ||
127+
node.declaration.type === AST_NODE_TYPES.FunctionDeclaration
128+
) {
129+
if (
130+
!node.declaration.id ||
131+
!isReactComponentLike(node.declaration, context.sourceCode)
132+
) {
133+
return;
134+
}
135+
136+
context.report({
137+
node,
138+
messageId: 'forbidden',
139+
fix: fixer => [
140+
fixer.replaceTextRange(
141+
[node.range[0], node.declaration.range[0]],
142+
'export '
143+
),
144+
],
145+
});
146+
return;
147+
}
148+
149+
if (node.declaration.type === AST_NODE_TYPES.Identifier) {
150+
const exportedName = node.declaration.name;
151+
const functionDeclaration = findTopLevelFunctionDeclaration(
152+
node.parent.body,
153+
exportedName
154+
);
155+
const variableDeclaration = findTopLevelVariableDeclaration(
156+
node.parent.body,
157+
exportedName
158+
);
159+
160+
const declarator = variableDeclaration?.declarations.find(
161+
decl =>
162+
decl.id.type === AST_NODE_TYPES.Identifier && decl.id.name === exportedName
163+
);
164+
165+
const isComponent = functionDeclaration
166+
? isReactComponentLike(functionDeclaration, context.sourceCode)
167+
: declarator
168+
? isReactComponentLike(declarator, context.sourceCode)
169+
: false;
170+
171+
const declarationToExport = functionDeclaration ?? variableDeclaration;
172+
if (!declarationToExport || !isComponent) {
173+
return;
174+
}
175+
176+
context.report({
177+
node,
178+
messageId: 'forbidden',
179+
fix: fixer => [
180+
fixer.insertTextBefore(declarationToExport, 'export '),
181+
fixer.remove(node),
182+
],
183+
});
184+
return;
185+
}
186+
},
187+
};
188+
},
189+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {AST_NODE_TYPES, type TSESTree} from '@typescript-eslint/utils';
2+
import type {SourceCode} from '@typescript-eslint/utils/ts-eslint';
3+
4+
function containsJsx(node: TSESTree.Node, sourceCode: SourceCode): boolean {
5+
const stack = [node];
6+
7+
while (stack.length) {
8+
const current = stack.pop();
9+
if (!current) {
10+
continue;
11+
}
12+
13+
if (
14+
current.type === AST_NODE_TYPES.JSXElement ||
15+
current.type === AST_NODE_TYPES.JSXFragment
16+
) {
17+
return true;
18+
}
19+
20+
const keys = sourceCode.visitorKeys[current.type] ?? [];
21+
22+
for (const key of keys) {
23+
const child = (current as any)[key];
24+
25+
if (Array.isArray(child)) {
26+
stack.push(...child);
27+
} else if (child) {
28+
stack.push(child);
29+
}
30+
}
31+
}
32+
33+
return false;
34+
}
35+
36+
function getFunctionName(
37+
node:
38+
| TSESTree.FunctionDeclaration
39+
| TSESTree.FunctionExpression
40+
| TSESTree.ArrowFunctionExpression
41+
) {
42+
return (
43+
(node.type === AST_NODE_TYPES.FunctionDeclaration ||
44+
node.type === AST_NODE_TYPES.FunctionExpression) &&
45+
node.id?.name
46+
);
47+
}
48+
49+
function isPascalCase(name: string) {
50+
return /^[A-Z][A-Za-z0-9]*$/u.test(name);
51+
}
52+
53+
function isStyledCallExpression(node: TSESTree.Node) {
54+
if (node.type !== AST_NODE_TYPES.CallExpression) {
55+
return false;
56+
}
57+
const callee = node.callee;
58+
return (
59+
(callee.type === AST_NODE_TYPES.Identifier && callee.name === 'styled') ||
60+
(callee.type === AST_NODE_TYPES.MemberExpression &&
61+
callee.property.type === AST_NODE_TYPES.Identifier &&
62+
callee.property.name === 'styled')
63+
);
64+
}
65+
66+
function isStyledComponentInit(node: TSESTree.Node) {
67+
if (isStyledCallExpression(node)) {
68+
return true;
69+
}
70+
if (node.type === AST_NODE_TYPES.TaggedTemplateExpression) {
71+
return isStyledCallExpression(node.tag);
72+
}
73+
return false;
74+
}
75+
76+
export function isReactComponentLike(node: TSESTree.Node, sourceCode: SourceCode) {
77+
if (node.type === AST_NODE_TYPES.Identifier) {
78+
return isPascalCase(node.name);
79+
}
80+
81+
if (
82+
node.type === AST_NODE_TYPES.FunctionDeclaration ||
83+
node.type === AST_NODE_TYPES.FunctionExpression ||
84+
node.type === AST_NODE_TYPES.ArrowFunctionExpression
85+
) {
86+
const name = getFunctionName(node);
87+
if (name && isPascalCase(name)) {
88+
return true;
89+
}
90+
91+
return containsJsx(node.body, sourceCode);
92+
}
93+
94+
if (node.type === AST_NODE_TYPES.VariableDeclarator) {
95+
if (
96+
node.id.type !== AST_NODE_TYPES.Identifier ||
97+
!node.init ||
98+
!isPascalCase(node.id.name)
99+
) {
100+
return false;
101+
}
102+
103+
if (
104+
node.init.type === AST_NODE_TYPES.FunctionExpression ||
105+
node.init.type === AST_NODE_TYPES.ArrowFunctionExpression
106+
) {
107+
return containsJsx(node.init, sourceCode);
108+
}
109+
110+
if (isStyledComponentInit(node.init)) {
111+
return true;
112+
}
113+
114+
return false;
115+
}
116+
117+
return false;
118+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// eslint-disable-next-line @typescript-eslint/no-restricted-types
2+
export function lazy<Value extends object, Args extends unknown[]>(
3+
getValue: (...args: Args) => Value
4+
) {
5+
let value: Value | undefined;
6+
7+
return (...args: Args) => {
8+
return (value ??= getValue(...args));
9+
};
10+
}

0 commit comments

Comments
 (0)