From 0d9d92f58405c364644b5954efb402816fd3c5b3 Mon Sep 17 00:00:00 2001 From: Daniel C Date: Mon, 2 Mar 2026 13:51:53 -0800 Subject: [PATCH 1/3] feat: add ssr-browser-api-guard lint rule Detects browser-only globals (window, localStorage, navigator, etc.) in files without "use client" directive, which crash during SSR in server components. Complements browser-api-in-useeffect by targeting server component context. 510+ production errors/week from window/localStorage SSR crashes. --- src/rules/index.ts | 2 + src/rules/meta.ts | 1 + src/rules/ssr-browser-api-guard.ts | 157 ++++++++++++++++++++++++++++ tests/ssr-browser-api-guard.test.ts | 138 ++++++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 src/rules/ssr-browser-api-guard.ts create mode 100644 tests/ssr-browser-api-guard.test.ts diff --git a/src/rules/index.ts b/src/rules/index.ts index dd01b2a..651c2b0 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -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 = { 'no-relative-paths': noRelativePaths, @@ -101,4 +102,5 @@ export const rules: Record = { 'prefer-named-params': preferNamedParams, 'require-use-client': requireUseClient, 'no-server-import-in-client': noServerImportInClient, + 'ssr-browser-api-guard': ssrBrowserApiGuard, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index ef0a579..59ede79 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -32,6 +32,7 @@ export const rulePlatforms: Partial> = { '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'], diff --git a/src/rules/ssr-browser-api-guard.ts b/src/rules/ssr-browser-api-guard.ts new file mode 100644 index 0000000..54fa110 --- /dev/null +++ b/src/rules/ssr-browser-api-guard.ts @@ -0,0 +1,157 @@ +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; +} diff --git a/tests/ssr-browser-api-guard.test.ts b/tests/ssr-browser-api-guard.test.ts new file mode 100644 index 0000000..d037f4e --- /dev/null +++ b/tests/ssr-browser-api-guard.test.ts @@ -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
{width}
; + } + `; + 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
{token}
; + } + `; + 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
{lang}
; + } + `; + 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
{width}
; + } + `; + 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
Hello
; + } + `; + 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
Hello
; + } + `; + 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
{width}
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow access in event handlers', () => { + const code = ` + function Component() { + return ; + } + `; + 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
{isClient ? 'client' : 'server'}
; + } + `; + 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
Hello
; + } + `; + 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
{count}
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +}); From 9201e487bba5c6d075d331ff590c88b4d97c015d Mon Sep 17 00:00:00 2001 From: Daniel C Date: Tue, 3 Mar 2026 10:39:45 -0800 Subject: [PATCH 2/3] fix: update README docs and test count for new rule --- README.md | 3 ++- tests/config-modes.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 89390df..5704e61 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (50 total) +## Available Rules (51 total) ### Expo Router Rules @@ -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 diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index 112bd5e..81092d3 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -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); }); }); }); From 564a24777de36d56e1054d612dce0c66c2e625e7 Mon Sep 17 00:00:00 2001 From: Daniel C Date: Tue, 3 Mar 2026 17:03:19 -0800 Subject: [PATCH 3/3] style: format ssr-browser-api-guard with prettier --- src/rules/ssr-browser-api-guard.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/rules/ssr-browser-api-guard.ts b/src/rules/ssr-browser-api-guard.ts index 54fa110..d3f7d30 100644 --- a/src/rules/ssr-browser-api-guard.ts +++ b/src/rules/ssr-browser-api-guard.ts @@ -22,9 +22,7 @@ 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', - ); + 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, @@ -55,10 +53,7 @@ export function ssrBrowserApiGuard(ast: File, _code: string): LintResult[] { if (object.type !== 'Identifier' || !BROWSER_GLOBALS.includes(object.name)) return; // Skip `typeof window` checks themselves - if ( - path.parent.type === 'UnaryExpression' && - path.parent.operator === 'typeof' - ) { + if (path.parent.type === 'UnaryExpression' && path.parent.operator === 'typeof') { return; }