diff --git a/README.md b/README.md index 89390df..8baae42 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 | +| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) | ### React / JSX Rules diff --git a/src/rules/index.ts b/src/rules/index.ts index dd01b2a..98309fc 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 { noReactNativeInWeb } from './no-react-native-in-web'; 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, + 'no-react-native-in-web': noReactNativeInWeb, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index ef0a579..0952097 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'], + 'no-react-native-in-web': ['web'], // Expo + Web (shared frontend) 'no-relative-paths': ['expo', 'web'], diff --git a/src/rules/no-react-native-in-web.ts b/src/rules/no-react-native-in-web.ts new file mode 100644 index 0000000..2ebb70d --- /dev/null +++ b/src/rules/no-react-native-in-web.ts @@ -0,0 +1,46 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-react-native-in-web'; + +const REACT_NATIVE_MODULES = ['react-native', 'react-native-web']; + +export function noReactNativeInWeb(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + if (!REACT_NATIVE_MODULES.includes(source)) return; + + const { loc } = path.node; + results.push({ + rule: RULE_NAME, + message: `Do not import from '${source}' in web modules. Use platform-specific files (.native.tsx) or web-compatible alternatives`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + }, + + CallExpression(path) { + const { callee, loc } = path.node; + if (callee.type !== 'Identifier' || callee.name !== 'require') return; + + const arg = path.node.arguments[0]; + if (!arg || arg.type !== 'StringLiteral') return; + if (!REACT_NATIVE_MODULES.includes(arg.value)) return; + + results.push({ + rule: RULE_NAME, + message: `Do not require '${arg.value}' in web modules. Use platform-specific files (.native.tsx) or web-compatible alternatives`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + }, + }); + + return results; +} 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); }); }); }); diff --git a/tests/no-react-native-in-web.test.ts b/tests/no-react-native-in-web.test.ts new file mode 100644 index 0000000..ed12dd7 --- /dev/null +++ b/tests/no-react-native-in-web.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-react-native-in-web'] }; + +describe('no-react-native-in-web rule', () => { + it('should detect import from react-native', () => { + const code = ` + import { View, Text } from 'react-native'; + export default function App() { + return Hello; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-react-native-in-web'); + expect(results[0].message).toContain('react-native'); + expect(results[0].severity).toBe('error'); + }); + + it('should detect import from react-native-web', () => { + const code = ` + import { View } from 'react-native-web'; + export default function App() { + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('react-native-web'); + }); + + it('should detect require of react-native', () => { + const code = ` + const { View } = require('react-native'); + export default function App() { + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('react-native'); + }); + + it('should allow other imports', () => { + const code = ` + import React from 'react'; + import { useState } from 'react'; + export default function App() { + return
Hello
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow react-native subpackage imports', () => { + const code = ` + import something from 'react-native-safe-area-context'; + export default function App() { + return
Hello
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +});