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
3 changes: 2 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 (50 total)
## Available Rules (51 total)

### Expo Router Rules

Expand Down Expand Up @@ -155,6 +155,7 @@ const backendRules = getRulesForPlatform('backend');
| ---------------------------- | -------- | -------- | ----------------------------------------------------------------- |
| `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 |
| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) |

### React / JSX Rules

Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ 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';
import { noModuleLevelNew } from './no-module-level-new';

export const rules: Record<string, RuleFunction> = {
'no-relative-paths': noRelativePaths,
Expand Down Expand Up @@ -101,4 +102,5 @@ export const rules: Record<string, RuleFunction> = {
'prefer-named-params': preferNamedParams,
'require-use-client': requireUseClient,
'no-server-import-in-client': noServerImportInClient,
'no-module-level-new': noModuleLevelNew,
};
1 change: 1 addition & 0 deletions src/rules/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const rulePlatforms: Partial<Record<string, Platform[]>> = {
'no-tailwind-animation-classes': ['web'],
'require-use-client': ['web'],
'no-server-import-in-client': ['web'],
'no-module-level-new': ['web'],

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

const RULE_NAME = 'no-module-level-new';

const SAFE_CONSTRUCTORS = new Set([
'Error',
'TypeError',
'RangeError',
'ReferenceError',
'SyntaxError',
'URIError',
'URL',
'URLSearchParams',
'RegExp',
'Map',
'Set',
'WeakMap',
'WeakSet',
'Date',
'Promise',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'TextEncoder',
'TextDecoder',
]);

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

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

let name: string;
if (callee.type === 'Identifier') {
name = callee.name;
} else if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier'
) {
name = `${callee.object.name}.${
callee.property.type === 'Identifier' ? callee.property.name : '...'
}`;
} else {
name = 'Expression';
}

if (SAFE_CONSTRUCTORS.has(name)) {
return;
}

let currentPath = path.parentPath;
while (currentPath) {
const { type } = currentPath.node;
if (
type === 'FunctionDeclaration' ||
type === 'FunctionExpression' ||
type === 'ArrowFunctionExpression' ||
type === 'ClassDeclaration' ||
type === 'ClassExpression' ||
type === 'ClassMethod' ||
type === 'ClassPrivateMethod' ||
type === 'ObjectMethod'
) {
return;
}
currentPath = currentPath.parentPath as typeof currentPath;
}

results.push({
rule: RULE_NAME,
message: `\`new ${name}()\` at module level will execute during SSR and may crash or cause side effects. Move it inside a component, useEffect, or wrap it in a factory function.`,
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(50);
expect(ruleNames.length).toBe(51);
});
});
});
153 changes: 153 additions & 0 deletions tests/no-module-level-new.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { lintJsxCode } from '../src';

const config = { rules: ['no-module-level-new'] };

describe('no-module-level-new rule', () => {
it('should flag new QueryClient() at module level', () => {
const code = `
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function Layout({ children }) {
return <div>{children}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].rule).toBe('no-module-level-new');
expect(results[0].message).toContain('new QueryClient()');
expect(results[0].severity).toBe('error');
});

it('should flag new IntersectionObserver() at module level', () => {
const code = `
const observer = new IntersectionObserver(callback);
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('IntersectionObserver');
});

it('should flag unknown constructors at module level', () => {
const code = `
const thing = new SomeUnknownClass();
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('SomeUnknownClass');
});

it('should not flag new QueryClient() inside a function body', () => {
const code = `
function createClient() {
return new QueryClient();
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new QueryClient() inside useEffect callback', () => {
const code = `
function Component() {
useEffect(() => {
const qc = new QueryClient();
}, []);
return <div>Hello</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new QueryClient() inside arrow function', () => {
const code = `
const makeClient = () => {
return new QueryClient();
};
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new Error() at module level', () => {
const code = `
const NOT_FOUND = new Error('Not found');
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new URL() at module level', () => {
const code = `
const baseUrl = new URL('https://example.com');
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new Map() at module level', () => {
const code = `
const cache = new Map();
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new RegExp() at module level', () => {
const code = `
const pattern = new RegExp('\\\\d+');
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new Set() at module level', () => {
const code = `
const seen = new Set();
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag new Date() at module level', () => {
const code = `
const start = new Date();
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag constructors inside class methods', () => {
const code = `
class MyService {
init() {
this.client = new QueryClient();
}
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag constructors inside class bodies via property initializer', () => {
const code = `
class MyService {
createClient() {
return new QueryClient();
}
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should flag multiple module-level new expressions', () => {
const code = `
const a = new QueryClient();
const b = new WebSocket('ws://localhost');
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(2);
});
});
Loading