From 95b8126d3bfd334d1d288c23cfe3e99ca60a375b Mon Sep 17 00:00:00 2001 From: mrpopo573 <13628415+mrpopo573@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:32:28 +0000 Subject: [PATCH] feat: add no-empty-screen-component rule Flags a default-exported screen/route component whose body renders nothing (return null / return undefined / empty return / empty fragment). This is the orphan-stub signature behind a recurring class of blank mobile previews: the generation agent moves real UI into a route group (e.g. (tabs)/index.tsx) but leaves the top-level index.tsx as 'export default function Index(){ return null; }', so the '/' route renders nothing and the app boots to a white screen in both the web preview and Expo Go. Scoped to be single-file decidable and low false-positive: only fires on a default-exported PascalCase (or anonymous) component whose entire body is a single empty return, resolving identifier default exports to their in-scope declaration. Conditional early returns, loading/auth gates, real JSX (including childless elements like ), and non-default helpers are left alone. --- README.md | 19 +-- src/rules/index.ts | 6 + src/rules/no-empty-screen-component.ts | 157 ++++++++++++++++++++++++ tests/no-empty-screen-component.test.ts | 136 ++++++++++++++++++++ 4 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 src/rules/no-empty-screen-component.ts create mode 100644 tests/no-empty-screen-component.test.ts diff --git a/README.md b/README.md index e236cad..b88e089 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (55 total) +## Available Rules (56 total) @@ -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) diff --git a/src/rules/index.ts b/src/rules/index.ts index 2db279b..524783d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -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'; @@ -121,6 +125,7 @@ export const rules: Record = { '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, @@ -179,6 +184,7 @@ export const ruleMeta: Record = { '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, diff --git a/src/rules/no-empty-screen-component.ts b/src/rules/no-empty-screen-component.ts new file mode 100644 index 0000000..0f0ed16 --- /dev/null +++ b/src/rules/no-empty-screen-component.ts @@ -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 `` 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; +} diff --git a/tests/no-empty-screen-component.test.ts b/tests/no-empty-screen-component.test.ts new file mode 100644 index 0000000..323cedb --- /dev/null +++ b/tests/no-empty-screen-component.test.ts @@ -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 Gift Cards; + } + `; + 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 Ready; + } + `; + 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 ; + } + `; + 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 ; + } + `; + 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 <>Hi; + } + `; + 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); + }); +});