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);
+ });
+});