Skip to content
Merged
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
52 changes: 51 additions & 1 deletion 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 (45 total)
## Available Rules (50 total)

### Expo Router Rules

Expand Down Expand Up @@ -149,6 +149,13 @@ const backendRules = getRulesForPlatform('backend');
| `glass-interactive-prop` | warning | expo | GlassView in pressables needs `isInteractive={true}` |
| `glass-no-opacity-animation` | warning | expo | No opacity animations on GlassView |

### Next.js Rules

| Rule | Severity | Platform | Description |
| ---------------------------- | -------- | -------- | ----------------------------------------------------------------- |
| `require-use-client` | error | web | Files using client-only features must have "use client" directive |
| `no-server-import-in-client` | error | web | "use client" files must not import server-only modules |

### React / JSX Rules

| Rule | Severity | Platform | Description |
Expand Down Expand Up @@ -320,6 +327,49 @@ When using NativeTabs from expo-router/unstable-native-tabs, each screen needs 6
<GlassView style={{ transform: [{ scale: scaleAnim }] }} />
```

### `require-use-client`

```tsx
// Bad - uses hooks without "use client"
import { useState } from 'react';

export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

// Good - has "use client" directive
('use client');

import { useState } from 'react';

export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
```

### `no-server-import-in-client`

```tsx
// Bad - server-only module in client file
'use client';

import { cookies } from 'next/headers';

export function UserMenu() {
return <div>Menu</div>;
}

// Good - use server-only modules only in server components
import { cookies } from 'next/headers';

export function UserMenu() {
const session = cookies().get('session');
return <div>Menu</div>;
}
```

### `no-complex-jsx-expressions`

```jsx
Expand Down
4 changes: 4 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { noManualRetryLoop } from './no-manual-retry-loop';
import { noEmojiIcons } from './no-emoji-icons';
import { noSyncFs } from './no-sync-fs';
import { preferNamedParams } from './prefer-named-params';
import { requireUseClient } from './require-use-client';
import { noServerImportInClient } from './no-server-import-in-client';

export const rules: Record<string, RuleFunction> = {
'no-relative-paths': noRelativePaths,
Expand Down Expand Up @@ -97,4 +99,6 @@ export const rules: Record<string, RuleFunction> = {
'no-emoji-icons': noEmojiIcons,
'no-sync-fs': noSyncFs,
'prefer-named-params': preferNamedParams,
'require-use-client': requireUseClient,
'no-server-import-in-client': noServerImportInClient,
};
2 changes: 2 additions & 0 deletions src/rules/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const rulePlatforms: Partial<Record<string, Platform[]>> = {
'no-inline-script-code': ['web'],
'browser-api-in-useeffect': ['web'],
'no-tailwind-animation-classes': ['web'],
'require-use-client': ['web'],
'no-server-import-in-client': ['web'],

// Expo + Web (shared frontend)
'no-relative-paths': ['expo', 'web'],
Expand Down
77 changes: 77 additions & 0 deletions src/rules/no-server-import-in-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import traverse from '@babel/traverse';
import type { File } from '@babel/types';
import type { LintResult } from '../types';

const RULE_NAME = 'no-server-import-in-client';

const SERVER_ONLY_MODULES = ['server-only', 'next/headers'];

function isServerOnlyModule(source: string): boolean {
return SERVER_ONLY_MODULES.some((mod) => source === mod);
}

export function noServerImportInClient(ast: File, _code: string): LintResult[] {
const hasUseClient = ast.program.directives.some((d) => d.value.value === 'use client');

if (!hasUseClient) {
return [];
}

const results: LintResult[] = [];

traverse(ast, {
ImportDeclaration(path) {
// Skip type-only imports (erased at compile time)
if (path.node.importKind === 'type') return;

const source = path.node.source.value;
const { loc } = path.node;

if (isServerOnlyModule(source)) {
results.push({
rule: RULE_NAME,
message: `'${source}' is a server-only module and cannot be imported in a "use client" file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}
},

ExportNamedDeclaration(path) {
const { source, loc } = path.node;
if (!source) return;

if (path.node.exportKind === 'type') return;

if (isServerOnlyModule(source.value)) {
results.push({
rule: RULE_NAME,
message: `'${source.value}' is a server-only module and cannot be re-exported from a "use client" file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}
},

ExportAllDeclaration(path) {
const source = path.node.source.value;
const { loc } = path.node;

if (path.node.exportKind === 'type') return;

if (isServerOnlyModule(source)) {
results.push({
rule: RULE_NAME,
message: `'${source}' is a server-only module and cannot be re-exported from a "use client" file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}
},
});

return results;
}
79 changes: 79 additions & 0 deletions src/rules/require-use-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import traverse from '@babel/traverse';
import type { File } from '@babel/types';
import type { LintResult } from '../types';

const RULE_NAME = 'require-use-client';

export function requireUseClient(ast: File, _code: string): LintResult[] {
const hasDirective = ast.program.directives.some(
(d) => d.value.value === 'use client' || d.value.value === 'use server',
);

if (hasDirective) {
return [];
}

const results: LintResult[] = [];

traverse(ast, {
CallExpression(path) {
const { callee, loc } = path.node;

// React hooks: useState(), useEffect(), useRef(), etc.
if (callee.type === 'Identifier' && /^use[A-Z]/.test(callee.name)) {
results.push({
rule: RULE_NAME,
message: `'${callee.name}()' is a client-only hook. Add "use client" at the top of this file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}

// createContext()
if (callee.type === 'Identifier' && callee.name === 'createContext') {
results.push({
rule: RULE_NAME,
message: `'createContext()' is client-only. Add "use client" at the top of this file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}

// React.createContext()
if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'React' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'createContext'
) {
results.push({
rule: RULE_NAME,
message: `'React.createContext()' is client-only. Add "use client" at the top of this file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}
},

JSXAttribute(path) {
const { name, loc } = path.node;

// Event handler props: onClick, onChange, onSubmit, etc.
if (name.type === 'JSXIdentifier' && /^on[A-Z]/.test(name.name)) {
results.push({
rule: RULE_NAME,
message: `'${name.name}' is a client-only event handler. Add "use client" at the top of this file.`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
}
},
});

return results;
}
2 changes: 1 addition & 1 deletion tests/config-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('config modes', () => {
expect(ruleNames).toContain('no-relative-paths');
expect(ruleNames).toContain('expo-image-import');
expect(ruleNames).toContain('no-stylesheet-create');
expect(ruleNames.length).toBe(48);
expect(ruleNames.length).toBe(50);
});
});
});
106 changes: 106 additions & 0 deletions tests/no-server-import-in-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { lintJsxCode } from '../src';

const config = { rules: ['no-server-import-in-client'] };

describe('no-server-import-in-client rule', () => {
it('should flag server-only import in "use client" file', () => {
const code = `
"use client";
import 'server-only';
export function Component() {
return <div />;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].rule).toBe('no-server-import-in-client');
expect(results[0].message).toContain('server-only');
expect(results[0].severity).toBe('error');
});

it('should flag next/headers import in "use client" file', () => {
const code = `
"use client";
import { headers } from 'next/headers';
export function Component() {
return <div />;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('next/headers');
});

it('should flag multiple server-only imports', () => {
const code = `
"use client";
import 'server-only';
import { headers } from 'next/headers';
export function Component() {
return <div />;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(2);
});

it('should flag re-exports from server-only modules', () => {
const code = `
"use client";
export { headers } from 'next/headers';
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('re-exported');
});

it('should allow server-only imports without "use client" directive', () => {
const code = `
import 'server-only';
import { headers } from 'next/headers';
export function ServerComponent() {
return <div />;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow server-only imports in "use server" files', () => {
const code = `
"use server";
import { headers } from 'next/headers';
export async function getHeaders() {
return headers();
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow type-only imports from server modules', () => {
const code = `
"use client";
import type { HeadersFunction } from 'next/headers';
export function Component() {
return <div />;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow normal imports in "use client" files', () => {
const code = `
"use client";
import { useState } from 'react';
import Link from 'next/link';
export function Component() {
return <Link href="/">Home</Link>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});
});
Loading
Loading