Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/no-barrel-import-rn-wording.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
for (const request of importRequests) {
if (!request.importedName) continue;
Expand All @@ -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.";
Expand Down Expand Up @@ -90,7 +95,12 @@ export const noBarrelImport = defineRule<Rule>({
didReportForFile = true;
context.report({
node,
message: buildReportMessage(filename, resolvedImportPath, importRequests),
message: buildReportMessage(
filename,
resolvedImportPath,
importRequests,
classifyReactNativeFileTarget(context) === "react-native",
),
});
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand All @@ -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";
Loading