Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web');
const backendRules = getRulesForPlatform('backend');
```

## Available Rules (55 total)
## Available Rules (56 total)

<!-- AUTOGEN:RULES — managed by scripts/sync.ts; run `npm run sync` to update. -->

Expand Down Expand Up @@ -163,14 +163,15 @@ const backendRules = getRulesForPlatform('backend');

### React / JSX Rules

| Rule | Severity | Platform | Description |
| ---------------------------- | -------- | ------------ | --------------------------------------------- |
| `browser-api-in-useeffect` | warning | web | window/localStorage only in useEffect for SSR |
| `fetch-response-ok-check` | warning | web, backend | Check response.ok when using fetch |
| `no-class-components` | warning | expo, web | Use function components with hooks |
| `no-complex-jsx-expressions` | warning | expo, web | Avoid IIFEs and complex expressions in JSX |
| `no-inline-script-code` | error | web | Script tags should use template literals |
| `no-react-query-missing` | warning | expo, web | Use @tanstack/react-query for data fetching |
| Rule | Severity | Platform | Description |
| ---------------------------- | -------- | ------------ | --------------------------------------------------------------------------- |
| `browser-api-in-useeffect` | warning | web | window/localStorage only in useEffect for SSR |
| `fetch-response-ok-check` | warning | web, backend | Check response.ok when using fetch |
| `no-class-components` | warning | expo, web | Use function components with hooks |
| `no-complex-jsx-expressions` | warning | expo, web | Avoid IIFEs and complex expressions in JSX |
| `no-empty-screen-component` | error | expo, web | A default-exported screen/route component must render UI, not `return null` |
| `no-inline-script-code` | error | web | Script tags should use template literals |
| `no-react-query-missing` | warning | expo, web | Use @tanstack/react-query for data fetching |

### Screen Transitions Rules (react-native-screen-transitions)

Expand Down
6 changes: 6 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
meta as noComplexJsxExpressionsMeta,
} from './no-complex-jsx-expressions';
import { noEmojiIcons, meta as noEmojiIconsMeta } from './no-emoji-icons';
import {
noEmptyScreenComponent,
meta as noEmptyScreenComponentMeta,
} from './no-empty-screen-component';
import { noInlineScriptCode, meta as noInlineScriptCodeMeta } from './no-inline-script-code';
import { noInlineStyles, meta as noInlineStylesMeta } from './no-inline-styles';
import { noLooseEquality, meta as noLooseEqualityMeta } from './no-loose-equality';
Expand Down Expand Up @@ -121,6 +125,7 @@ export const rules: Record<string, RuleFunction> = {
'no-class-components': noClassComponents,
'no-complex-jsx-expressions': noComplexJsxExpressions,
'no-emoji-icons': noEmojiIcons,
'no-empty-screen-component': noEmptyScreenComponent,
'no-inline-script-code': noInlineScriptCode,
'no-inline-styles': noInlineStyles,
'no-loose-equality': noLooseEquality,
Expand Down Expand Up @@ -179,6 +184,7 @@ export const ruleMeta: Record<string, RuleMeta> = {
'no-class-components': noClassComponentsMeta,
'no-complex-jsx-expressions': noComplexJsxExpressionsMeta,
'no-emoji-icons': noEmojiIconsMeta,
'no-empty-screen-component': noEmptyScreenComponentMeta,
'no-inline-script-code': noInlineScriptCodeMeta,
'no-inline-styles': noInlineStylesMeta,
'no-loose-equality': noLooseEqualityMeta,
Expand Down
157 changes: 157 additions & 0 deletions src/rules/no-empty-screen-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import traverse from '@babel/traverse';
import type {
ArrowFunctionExpression,
File,
FunctionDeclaration,
FunctionExpression,
} from '@babel/types';
import type { LintResult, Platform } from '../types';

const RULE_NAME = 'no-empty-screen-component';

export const meta = {
name: 'no-empty-screen-component',
severity: 'error' as const,
platforms: ['expo', 'web'] as Platform[] | null,
category: 'React / JSX',
description: 'A default-exported screen/route component must render UI, not `return null`',
};

type ComponentFn = FunctionDeclaration | FunctionExpression | ArrowFunctionExpression;

/**
* True when the function unconditionally renders nothing: its body is a single
* `return` of null / undefined / empty fragment (or an implicit `return null`),
* with no other statements and no other return paths. A concise-body arrow
* (`() => null`) counts too.
*
* We deliberately do NOT flag components that have any conditional return, any
* other statement, or that return real JSX anywhere. A loading gate, an auth
* guard, or a `+not-found` screen all have branches or real JSX and are left
* alone. This targets only the orphan stub `export default function X(){ return
* null; }` that ships a blank "/" route.
*/
function rendersNothing(fn: ComponentFn): boolean {
const body = fn.body;

// Concise arrow body: () => null | () => undefined
if (body.type !== 'BlockStatement') {
return (
body.type === 'NullLiteral' ||
(body.type === 'Identifier' && body.name === 'undefined') ||
isEmptyFragment(body)
);
}

const statements = body.body;
if (statements.length !== 1) {
return false;
}

const only = statements[0];
if (only.type !== 'ReturnStatement') {
return false;
}

const arg = only.argument;
// `return;` (implicit undefined), `return null;`, `return undefined;`, `return <></>;`
if (arg === null || arg === undefined) {
return true;
}
if (arg.type === 'NullLiteral') {
return true;
}
if (arg.type === 'Identifier' && arg.name === 'undefined') {
return true;
}
return isEmptyFragment(arg);
}

function isEmptyFragment(node: { type: string; children?: unknown[] }): boolean {
// Only a fragment (`<></>` / `<> </>`) renders nothing. A real JSX element
// like `<View />` renders UI even with no children, so it is NOT empty.
if (node.type !== 'JSXFragment') {
return false;
}
const children = (node.children ?? []) as Array<{ type: string; value?: string }>;
return children.every((c) => c.type === 'JSXText' && (c.value ?? '').trim() === '');
}

/**
* A component looks like a screen/route component when its name is PascalCase
* (React convention) or it is an anonymous default-exported function. Helper
* functions that legitimately return null are typically camelCase or not the
* default export, so this keeps the rule focused on rendered components.
*/
function looksLikeComponentName(name: string | null | undefined): boolean {
if (!name) {
return true; // anonymous default export -> treat as a component
}
return /^[A-Z]/.test(name);
}

export function noEmptyScreenComponent(ast: File, _code: string): LintResult[] {
const results: LintResult[] = [];

const report = (fn: ComponentFn, name: string | null | undefined) => {
if (!looksLikeComponentName(name)) {
return;
}
if (!rendersNothing(fn)) {
return;
}
const loc = fn.loc;
results.push({
rule: RULE_NAME,
message:
'This default-exported screen renders nothing (`return null`). A route/screen ' +
'component must render UI, or the app shows a blank screen. If real content ' +
'lives in a route group (e.g. `(tabs)/index.tsx`), point this route at it or ' +
'move the content here.',
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
};

traverse(ast, {
ExportDefaultDeclaration(path) {
const decl = path.node.declaration;

// export default function Foo() {}
if (decl.type === 'FunctionDeclaration') {
report(decl, decl.id?.name ?? null);
return;
}

// export default () => null / export default function () {}
if (decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression') {
report(decl, decl.type === 'FunctionExpression' ? (decl.id?.name ?? null) : null);
return;
}

// export default Foo (identifier) -> resolve to the declaration in scope
if (decl.type === 'Identifier') {
const name = decl.name;
const binding = path.scope.getBinding(name);
const node = binding?.path.node;
if (!node) {
return;
}
if (node.type === 'FunctionDeclaration') {
report(node, node.id?.name ?? name);
return;
}
if (
node.type === 'VariableDeclarator' &&
node.init &&
(node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')
) {
report(node.init, name);
}
}
},
});

return results;
}
136 changes: 136 additions & 0 deletions tests/no-empty-screen-component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { lintJsxCode } from '../src';

const config = { rules: ['no-empty-screen-component'] };

describe('no-empty-screen-component rule', () => {
it('flags the classic orphan stub: export default function Index() { return null; }', () => {
const code = `
export default function Index() {
return null;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].rule).toBe('no-empty-screen-component');
expect(results[0].severity).toBe('error');
expect(results[0].message).toContain('renders nothing');
});

it('flags an implicit empty return (`return;`)', () => {
const code = `
export default function Screen() {
return;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

it('flags return undefined', () => {
const code = `
export default function Home() {
return undefined;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

it('flags an empty fragment return', () => {
const code = `
export default function Home() {
return <></>;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

it('flags a concise arrow default export returning null', () => {
const code = `const Index = () => null;\nexport default Index;`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

it('flags anonymous default export returning null', () => {
const code = `export default function () { return null; }`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

it('flags identifier default export resolving to a null-returning function', () => {
const code = `
function Home() {
return null;
}
export default Home;
`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});

// --- should NOT flag ---

it('does not flag a component that returns real JSX', () => {
const code = `
export default function Index() {
return <View><Text>Gift Cards</Text></View>;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a component with a conditional early return null (loading/auth gate)', () => {
const code = `
export default function Screen() {
if (!ready) return null;
return <View><Text>Ready</Text></View>;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a component that does work before rendering', () => {
const code = `
export default function Screen() {
const insets = useSafeAreaInsets();
return <View style={{ paddingTop: insets.top }} />;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a non-default helper that returns null', () => {
const code = `
export function maybeRender() {
return null;
}
export default function Screen() {
return <View />;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a camelCase default export (not a component)', () => {
const code = `
const config = () => null;
export default config;
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a fragment that contains real children', () => {
const code = `
export default function Home() {
return <><Text>Hi</Text></>;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(0);
});

it('does not flag a fragment default export with an empty fragment but only whitespace', () => {
// whitespace-only fragment IS flagged (renders nothing); real children are not.
const code = `
export default function Home() {
return <> </>;
}
`;
expect(lintJsxCode(code, config)).toHaveLength(1);
});
});
Loading