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 |
| `ssr-browser-api-guard` | error | web | Browser globals in server components crash 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 { ssrBrowserApiGuard } from './ssr-browser-api-guard';

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,
'ssr-browser-api-guard': ssrBrowserApiGuard,
};
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'],
'ssr-browser-api-guard': ['web'],

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

const RULE_NAME = 'ssr-browser-api-guard';

const BROWSER_GLOBALS = [
'window',
'localStorage',
'sessionStorage',
'document',
'navigator',
'location',
'history',
'alert',
'confirm',
'prompt',
'self',
];

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

// Check if file has "use client" directive
const hasUseClient = ast.program.directives?.some((d) => d.value.value === 'use client');

// If the file has "use client", the existing browser-api-in-useeffect rule handles it.
// This rule targets files WITHOUT "use client" that still reference browser APIs,
// which will crash during SSR (server components, API routes, etc.)
if (hasUseClient) return results;

traverse(ast, {
// Direct identifier usage: alert('hi'), confirm('sure?')
CallExpression(path) {
const { callee, loc } = path.node;
if (callee.type !== 'Identifier') return;
if (!['alert', 'confirm', 'prompt'].includes(callee.name)) return;

if (isInSafeContext(path)) return;

results.push({
rule: RULE_NAME,
message: `'${callee.name}()' is a browser-only API and will crash during SSR. Add a "use client" directive or guard with 'typeof window !== "undefined"'`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
},

MemberExpression(path) {
const { object, loc } = path.node;

if (object.type !== 'Identifier' || !BROWSER_GLOBALS.includes(object.name)) return;

// Skip `typeof window` checks themselves
if (path.parent.type === 'UnaryExpression' && path.parent.operator === 'typeof') {
return;
}

if (isInSafeContext(path)) return;

results.push({
rule: RULE_NAME,
message: `'${object.name}' is a browser-only global and will crash during SSR in a server component. Add a "use client" directive, wrap in useEffect, or guard with 'typeof ${object.name} !== "undefined"'`,
line: loc?.start.line ?? 0,
column: loc?.start.column ?? 0,
severity: 'error',
});
},
});

return results;
}

function isInSafeContext(path: any): boolean {
let currentPath = path.parentPath;

while (currentPath) {
const { type } = currentPath.node;

// Inside useEffect callback — safe (client-only execution)
if (
type === 'CallExpression' &&
currentPath.node.callee.type === 'Identifier' &&
(currentPath.node.callee.name === 'useEffect' ||
currentPath.node.callee.name === 'useLayoutEffect')
) {
return true;
}

// Inside typeof check — safe
if (type === 'IfStatement' || type === 'ConditionalExpression') {
if (hasTypeofGuard(currentPath.node.test)) {
return true;
}
}

// Inside && short-circuit with typeof guard: typeof window !== 'undefined' && window.foo
if (
type === 'LogicalExpression' &&
currentPath.node.operator === '&&' &&
hasTypeofGuard(currentPath.node.left)
) {
return true;
}

// Inside event handler (onClick, onSubmit, etc.) — safe (only runs on client)
if (
type === 'JSXAttribute' &&
currentPath.node.name?.type === 'JSXIdentifier' &&
currentPath.node.name.name.startsWith('on')
) {
return true;
}

// Inside a function passed to addEventListener — safe
if (
type === 'CallExpression' &&
currentPath.node.callee?.type === 'MemberExpression' &&
currentPath.node.callee.property?.type === 'Identifier' &&
currentPath.node.callee.property.name === 'addEventListener'
) {
return true;
}

currentPath = currentPath.parentPath;
}

return false;
}

function hasTypeofGuard(node: any): boolean {
if (!node) return false;

// typeof window !== 'undefined'
if (
node.type === 'BinaryExpression' &&
node.left?.type === 'UnaryExpression' &&
node.left.operator === 'typeof' &&
node.left.argument?.type === 'Identifier' &&
BROWSER_GLOBALS.includes(node.left.argument.name)
) {
return true;
}

// Recursive check for logical expressions
if (node.type === 'LogicalExpression') {
return hasTypeofGuard(node.left) || hasTypeofGuard(node.right);
}

return false;
}
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);
});
});
});
138 changes: 138 additions & 0 deletions tests/ssr-browser-api-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import { lintJsxCode } from '../src';

const config = { rules: ['ssr-browser-api-guard'] };

describe('ssr-browser-api-guard rule', () => {
it('should detect window access in a server component (no "use client")', () => {
const code = `
function Component() {
const width = window.innerWidth;
return <div>{width}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].rule).toBe('ssr-browser-api-guard');
expect(results[0].message).toContain('SSR');
expect(results[0].severity).toBe('error');
});

it('should detect localStorage in a server component', () => {
const code = `
function Component() {
const token = localStorage.getItem('token');
return <div>{token}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('localStorage');
});

it('should detect navigator access in a server component', () => {
const code = `
function Component() {
const lang = navigator.language;
return <div>{lang}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('navigator');
});

it('should NOT flag files with "use client" directive', () => {
const code = `
"use client";
function Component() {
const width = window.innerWidth;
return <div>{width}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow access inside useEffect', () => {
const code = `
function Component() {
useEffect(() => {
const width = window.innerWidth;
console.log(width);
}, []);
return <div>Hello</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow access with typeof guard', () => {
const code = `
function Component() {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
}
return <div>Hello</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow access with && short-circuit typeof guard', () => {
const code = `
function Component() {
const width = typeof window !== 'undefined' && window.innerWidth;
return <div>{width}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should allow access in event handlers', () => {
const code = `
function Component() {
return <button onClick={() => window.scrollTo(0, 0)}>Top</button>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should not flag typeof window checks themselves', () => {
const code = `
function Component() {
const isClient = typeof window !== 'undefined';
return <div>{isClient ? 'client' : 'server'}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});

it('should detect direct alert() call in server component', () => {
const code = `
function Component() {
alert('hello');
return <div>Hello</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(1);
expect(results[0].message).toContain('alert');
});

it('should allow code without browser APIs', () => {
const code = `
function Component() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`;
const results = lintJsxCode(code, config);
expect(results).toHaveLength(0);
});
});
Loading