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/fix-expo-metro-config-subpaths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-doctor/core": patch
---

Keep `@expo/metro-config` as a direct dependency when project code imports a package subpath that the `expo/metro-config` umbrella does not expose.
6 changes: 6 additions & 0 deletions .changeset/fix-fbt-component-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"oxlint-plugin-react-doctor": patch
"react-doctor": patch
---

Fix `rn-no-raw-text` false positives in components that return only direct `<fbt>` or `<fbs>` elements.
7 changes: 7 additions & 0 deletions .changeset/fix-fbt-in-text-wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Prevent `rn-no-raw-text` reports for `<fbt>` content passed through verified React Native text wrappers.
7 changes: 7 additions & 0 deletions .changeset/fix-test-noise-application-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Run `test-noise` rules in ambiguous product-named directories such as `tools`, `demo`, and `migrations` when they are below a recognized application source root. Explicit test surfaces and root-level tooling or example directories remain excluded.
18 changes: 16 additions & 2 deletions packages/core/src/checks/expo/check-flagged-dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { Diagnostic } from "../../types/index.js";
import type { ExpoCheckContext } from "./expo-check-context.js";
import { buildExpoDiagnostic } from "./utils/build-expo-diagnostic.js";
import { hasStaticModuleSubpath } from "./utils/has-static-module-subpath.js";
import { isExpoSdkAtLeast } from "./utils/is-expo-sdk-at-least.js";

interface FlaggedDependency {
readonly packageName: string;
readonly rule: string;
readonly message: string;
readonly help: string;
readonly skipWhenSubpathImported?: boolean;
/**
* Lowest Expo SDK major the finding applies to. When set, the entry
* stays quiet unless the resolved SDK major is known AND at least this
Expand Down Expand Up @@ -95,6 +97,7 @@ const FLAGGED_DEPENDENCIES: ReadonlyArray<FlaggedDependency> = [
message:
'"@expo/metro-config" should not be a direct dependency. Expo pins the compatible Metro config, and a direct entry can drift to a version that breaks bundling',
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js",
skipWhenSubpathImported: true,
},
{
packageName: "@types/react-native",
Expand Down Expand Up @@ -165,8 +168,19 @@ const FLAGGED_DEPENDENCIES: ReadonlyArray<FlaggedDependency> = [
export const checkExpoFlaggedDependencies = (context: ExpoCheckContext): Diagnostic[] =>
FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
if (flaggedDependency.minSdkMajor === undefined) return true;
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
if (
flaggedDependency.minSdkMajor !== undefined &&
!isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor)
) {
return false;
}
if (
flaggedDependency.skipWhenSubpathImported &&
hasStaticModuleSubpath(context.rootDirectory, flaggedDependency.packageName)
) {
return false;
}
return true;
}).map((flaggedDependency) =>
buildExpoDiagnostic({
rule: flaggedDependency.rule,
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/checks/expo/utils/has-static-module-subpath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as fs from "node:fs";
import { collectStaticModuleSpecifiers } from "../../../project-analysis/utils/collect-static-module-specifiers.js";
import { walkSourceTreeFiles } from "../../../utils/walk-source-tree-files.js";

const JAVASCRIPT_MODULE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;

export const hasStaticModuleSubpath = (rootDirectory: string, packageName: string): boolean => {
const packageSubpathPrefix = `${packageName}/`;

for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
if (!JAVASCRIPT_MODULE_FILE_PATTERN.test(name)) continue;

let sourceText: string;
try {
sourceText = fs.readFileSync(absolutePath, "utf-8");
} catch {
continue;
}
if (!sourceText.includes(packageSubpathPrefix)) continue;

let moduleSpecifiers: Set<string>;
try {
moduleSpecifiers = collectStaticModuleSpecifiers(sourceText, { filePath: absolutePath });
} catch {
continue;
}
for (const moduleSpecifier of moduleSpecifiers) {
if (moduleSpecifier.startsWith(packageSubpathPrefix)) return true;
}
}

return false;
};
72 changes: 72 additions & 0 deletions packages/core/tests/check-expo-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,78 @@ describe("checkExpoProject — redundant transitive dependencies", () => {
),
).toHaveLength(1);
});

it("keeps @expo/metro-config when a package subpath is imported", () => {
const projectDirectory = makeProjectDirectory();
writePackageJson(projectDirectory, {
name: "expo-app",
dependencies: {
expo: "~57.0.18",
"@expo/metro-config": "57.0.12",
},
});
writeFile(
projectDirectory,
"metro.transformer.cjs",
`const upstreamTransformer = require("@expo/metro-config/babel-transformer");`,
);

const diagnostics = checkExpoProject(
projectDirectory,
buildExpoProject(projectDirectory, "~57.0.18"),
);
expect(
rulesOf(diagnostics).filter((rule) => rule === "expo-no-redundant-dependency"),
).toHaveLength(0);
});

it("still flags @expo/metro-config when only the package root is imported", () => {
const projectDirectory = makeProjectDirectory();
writePackageJson(projectDirectory, {
name: "expo-app",
dependencies: {
expo: "~57.0.18",
"@expo/metro-config": "57.0.12",
},
});
writeFile(
projectDirectory,
"metro.config.js",
`const { getDefaultConfig } = require("@expo/metro-config");`,
);

const diagnostics = checkExpoProject(
projectDirectory,
buildExpoProject(projectDirectory, "~57.0.18"),
);
expect(
rulesOf(diagnostics).filter((rule) => rule === "expo-no-redundant-dependency"),
).toHaveLength(1);
});

it("still flags @expo/metro-config when a package subpath only appears in a comment", () => {
const projectDirectory = makeProjectDirectory();
writePackageJson(projectDirectory, {
name: "expo-app",
dependencies: {
expo: "~57.0.18",
"@expo/metro-config": "57.0.12",
},
});
writeFile(
projectDirectory,
"metro.config.js",
`// require("@expo/metro-config/babel-transformer");\nmodule.exports = {};`,
);

const diagnostics = checkExpoProject(
projectDirectory,
buildExpoProject(projectDirectory, "~57.0.18"),
);
expect(
rulesOf(diagnostics).filter((rule) => rule === "expo-no-redundant-dependency"),
).toHaveLength(1);
});
});

describe("checkExpoProject — dependency overrides", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// rule: rn-no-raw-text
// verdict: pass
// weakness: wrapper-transparency
// source: issue #1729

import { Text } from "react-native";

export const FbtLabel = () => <fbt desc="d">Travel with confidence</fbt>;

export const StringLabel = () => "Travel with confidence";

export const Screen = () => (
<Text>
<FbtLabel />
<StringLabel />
</Text>
);
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ export const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
// whether they wrap children in a <Text> is a per-project provider choice, so
// they belong in an opt-in `transparentComponents` config instead.
// Ref: https://github.com/millionco/react-doctor/issues/581
export const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set(["Fragment", "fbt", "fbs"]);
export const REACT_NATIVE_TRANSLATION_TEXT_COMPONENTS = new Set(["fbt", "fbs"]);

export const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set([
"Fragment",
...REACT_NATIVE_TRANSLATION_TEXT_COMPONENTS,
]);

// HACK: Maps (not plain objects) so that an unusual `import { constructor }
// from "react-native"` (or any other Object.prototype name) doesn't fall
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,12 @@ describe("no-placeholder-only-field", () => {
expect(result.diagnostics).toHaveLength(1);
});

it("does not report placeholder-only fields in non-production files", () => {
it("reports placeholder-only fields in application demo directories", () => {
const result = runRule(
noPlaceholderOnlyField,
`const Example = () => <input placeholder="Demo value" />;`,
{ filename: "src/demo/example.tsx" },
);
expect(result.diagnostics).toHaveLength(0);
expect(result.diagnostics).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,13 @@ describe("no-create-object-url-without-revoke", () => {
expect(result.diagnostics).toHaveLength(0);
});

it("stays quiet in a demo file", () => {
it("reports in application demo directories", () => {
const result = runRule(
noCreateObjectUrlWithoutRevoke,
`export default () => <a href={URL.createObjectURL(blob)}>download</a>;`,
{ filename: "/src/demos/index.tsx" },
);
expect(result.diagnostics).toHaveLength(0);
expect(result.diagnostics).toHaveLength(1);
});

it("stays quiet when URL is a local binding, not the DOM global", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ describe("nextjs/nextjs-no-a-element — regressions", () => {
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it.each([
"src/components/tools/widget.tsx",
"src/components/demo/widget.tsx",
"src/migrations/widget.tsx",
])("still flags an internal route in application code at %s", (filename) => {
const result = runRule(
nextjsNoAElement,
`export default function C() { return <a href="/about">About</a>; }`,
{ filename },
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("stays silent for an internal route in a root-level tooling directory", () => {
const result = runRule(
nextjsNoAElement,
`export default function C() { return <a href="/about">About</a>; }`,
{ filename: "tools/widget.tsx" },
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent on a download anchor", () => {
const result = runRule(
nextjsNoAElement,
Expand Down
Loading
Loading