diff --git a/.changeset/no-barrel-import-rn-wording.md b/.changeset/no-barrel-import-rn-wording.md new file mode 100644 index 000000000..bf32b9579 --- /dev/null +++ b/.changeset/no-barrel-import-rn-wording.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +`no-barrel-import` messaging is now framework-aware: files that target React Native / Expo (per the nearest `package.json` platform, native/web file extensions, and the project `framework` setting) say the barrel import "ships extra code in your app bundle & slows startup" instead of the web-only "slows page load" wording. Web projects, web-extension files inside RN monorepos, and projects with an unknown framework keep the existing page-load wording. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.test.ts new file mode 100644 index 000000000..f2b868976 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.test.ts @@ -0,0 +1,101 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { noBarrelImport } from "./no-barrel-import.js"; + +const code = `import { Button } from "./components"; +void Button; +`; + +describe("no-barrel-import", () => { + let temporaryDirectory = ""; + let entryFilename = ""; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-no-barrel-import-")); + const componentsDirectory = path.join(temporaryDirectory, "src", "components"); + fs.mkdirSync(componentsDirectory, { recursive: true }); + fs.writeFileSync( + path.join(componentsDirectory, "Button.tsx"), + "export const Button = () => null;\n", + ); + fs.writeFileSync( + path.join(componentsDirectory, "index.ts"), + "export { Button } from './Button';\n", + ); + entryFilename = path.join(temporaryDirectory, "src", "App.tsx"); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it("uses page-load wording for web framework projects", () => { + const result = runRule(noBarrelImport, code, { + filename: entryFilename, + settings: { "react-doctor": { framework: "nextjs" } }, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toBe( + 'This ships extra code to your users & slows page load. Import directly from "./components/Button".', + ); + }); + + it("uses app-startup wording for react-native projects", () => { + const result = runRule(noBarrelImport, code, { + filename: entryFilename, + settings: { "react-doctor": { framework: "react-native" } }, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toBe( + 'This ships extra code in your app bundle & slows startup. Import directly from "./components/Button".', + ); + }); + + it("uses app-startup wording for expo projects", () => { + const result = runRule(noBarrelImport, code, { + filename: entryFilename, + settings: { "react-doctor": { framework: "expo" } }, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toContain( + "This ships extra code in your app bundle & slows startup.", + ); + }); + + it("falls back to page-load wording when the framework is unknown", () => { + const result = runRule(noBarrelImport, code, { + filename: entryFilename, + settings: { "react-doctor": { framework: "unknown" } }, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toContain( + "This ships extra code to your users & slows page load.", + ); + }); + + it("uses page-load wording for web-extension files inside react-native projects", () => { + const webEntryFilename = path.join(temporaryDirectory, "src", "App.web.tsx"); + + const result = runRule(noBarrelImport, code, { + filename: webEntryFilename, + settings: { "react-doctor": { framework: "react-native" } }, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toContain( + "This ships extra code to your users & slows page load.", + ); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.ts index 916320abd..911c27410 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-barrel-import.ts @@ -3,6 +3,7 @@ import { defineRule } from "../../utils/define-rule.js"; import { normalizeFilename } from "../../utils/normalize-filename.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { isBarrelIndexModule } from "../../utils/is-barrel-index-module.js"; +import { classifyReactNativeFileTarget } from "../../utils/is-react-native-file.js"; import { resolveBarrelExportFilePath } from "../../utils/resolve-barrel-export-file-path.js"; import { resolveRelativeImportPath } from "../../utils/resolve-relative-import-path.js"; import type { Rule } from "../../utils/rule.js"; @@ -37,7 +38,11 @@ const buildReportMessage = ( filename: string, barrelFilePath: string, importRequests: RuntimeImportRequest[], + isReactNativeTarget: boolean, ): string => { + const costSentence = isReactNativeTarget + ? "This ships extra code in your app bundle & slows startup." + : "This ships extra code to your users & slows page load."; const directImportSources = new Set(); for (const request of importRequests) { if (!request.importedName) continue; @@ -49,11 +54,11 @@ const buildReportMessage = ( if (directImportSources.size === 1) { const [directImportSource] = directImportSources; - return `This ships extra code to your users & slows page load. Import directly from "${directImportSource}".`; + return `${costSentence} Import directly from "${directImportSource}".`; } if (directImportSources.size > 1) { - return `This ships extra code to your users & slows page load. Import directly from: ${[...directImportSources].map((source) => `"${source}"`).join(", ")}.`; + return `${costSentence} Import directly from: ${[...directImportSources].map((source) => `"${source}"`).join(", ")}.`; } return "Importing from an index file pulls in extra code. Import directly from the source file instead."; @@ -90,7 +95,12 @@ export const noBarrelImport = defineRule({ didReportForFile = true; context.report({ node, - message: buildReportMessage(filename, resolvedImportPath, importRequests), + message: buildReportMessage( + filename, + resolvedImportPath, + importRequests, + classifyReactNativeFileTarget(context) === "react-native", + ), }); } }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-native-file.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-native-file.ts index f01f0df4c..4e08d9b8f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-native-file.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-native-file.ts @@ -17,44 +17,49 @@ const WEB_FILE_EXTENSION_PATTERN = /\.web\.[cm]?[jt]sx?$/; // project framework) doesn't already cover them. const NATIVE_FILE_EXTENSION_PATTERN = /\.(?:ios|android|native)\.[cm]?[jt]sx?$/; -// Returns true when react-native rules should be evaluated for `filename` -// given the surrounding `context.settings["react-doctor"].framework` hint. +// Classifies which platform `filename` targets given the surrounding +// `context.settings["react-doctor"].framework` hint. `isReactNativeFileActive` +// (whether RN rules should run) treats "unknown" as active; callers that only +// branch on wording should treat "unknown" as web. // // Decision order (the first matching row wins): // // 1. Filename ends with a native-only extension (`.ios.tsx`, `.android.tsx`, -// `.native.tsx`) → ACTIVE. These files always target RN. -// 2. Filename ends with a web extension (`.web.tsx`) → INACTIVE. -// 3. Nearest package.json classifies as "web" → INACTIVE. -// 4. Nearest package.json classifies as "expo" or "react-native" → ACTIVE. +// `.native.tsx`) → "react-native". These files always target RN. +// 2. Filename ends with a web extension (`.web.tsx`) → "web". +// 3. Nearest package.json classifies as "web" → "web". +// 4. Nearest package.json classifies as "expo" or "react-native" → "react-native". // 5. Nearest package.json classifies as "unknown" → fall back to the // project-level framework setting: -// • `react-native` or `expo` → ACTIVE +// • `react-native` or `expo` → "react-native" // • any other known framework (`nextjs`, `vite`, `cra`, `remix`, -// `gatsby`, `tanstack-start`) → INACTIVE -// • `unknown` or missing → ACTIVE (conservatively keep the old -// behavior so test fixtures and CLI invocations without a -// discoverable framework still report RN issues; the project -// capability gate in `runOxlint` already prevents RN rules from -// loading at all unless the project is RN-aware). +// `gatsby`, `tanstack-start`) → "web" +// • `unknown` or missing → "unknown" (`isReactNativeFileActive` +// conservatively keeps RN rules active here so test fixtures and +// CLI invocations without a discoverable framework still report +// RN issues; the project capability gate in `runOxlint` already +// prevents RN rules from loading at all unless the project is +// RN-aware). // // `context.filename` may be unavailable in stripped-down test -// harnesses; in that case we keep RN rules active so the rule body can -// proceed. -export const isReactNativeFileActive = (context: RuleContext): boolean => { +// harnesses; in that case the target is "unknown" and RN rules stay +// active so the rule body can proceed. +export type ReactNativeFileTarget = "react-native" | "web" | "unknown"; + +export const classifyReactNativeFileTarget = (context: RuleContext): ReactNativeFileTarget => { const rawFilename = context.filename; - if (!rawFilename) return true; + if (!rawFilename) return "unknown"; const filename = normalizeFilename(rawFilename); - if (NATIVE_FILE_EXTENSION_PATTERN.test(filename)) return true; - if (WEB_FILE_EXTENSION_PATTERN.test(filename)) return false; + if (NATIVE_FILE_EXTENSION_PATTERN.test(filename)) return "react-native"; + if (WEB_FILE_EXTENSION_PATTERN.test(filename)) return "web"; const packagePlatform = classifyPackagePlatform(filename); - if (packagePlatform === "web") return false; - if (packagePlatform === "expo" || packagePlatform === "react-native") return true; + if (packagePlatform === "web") return "web"; + if (packagePlatform === "expo" || packagePlatform === "react-native") return "react-native"; const framework = getReactDoctorStringSetting(context.settings, "framework"); - if (framework === "react-native" || framework === "expo") return true; + if (framework === "react-native" || framework === "expo") return "react-native"; if ( framework === "nextjs" || framework === "vite" || @@ -63,7 +68,10 @@ export const isReactNativeFileActive = (context: RuleContext): boolean => { framework === "gatsby" || framework === "tanstack-start" ) { - return false; + return "web"; } - return true; + return "unknown"; }; + +export const isReactNativeFileActive = (context: RuleContext): boolean => + classifyReactNativeFileTarget(context) !== "web";