Skip to content

Commit cc78fa3

Browse files
authored
FP104: respect React Native module removal versions (#1878)
* fix: respect React Native module removal versions * fix: retain reports for unbounded native version ranges
1 parent dfb598a commit cc78fa3

14 files changed

Lines changed: 303 additions & 141 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"oxlint-plugin-react-doctor": patch
3+
"eslint-plugin-react-doctor": patch
4+
"react-doctor": patch
5+
---
6+
7+
Respect the installed or declared React Native version before reporting removed AsyncStorage and WebView exports. Reuse package-version resolution and track manifests for diagnostic cache invalidation.

‎packages/core/tests/cross-file-rule-ids.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ describe("CROSS_FILE_RULE_IDS", () => {
242242
"rendering-hydration-mismatch-time",
243243
"rendering-hydration-no-flicker",
244244
"rerender-memo-with-default-value",
245+
"rn-no-deprecated-modules",
245246
"rn-no-legacy-shadow-styles",
246247
"rn-no-raw-text",
247248
"rn-prefer-expo-image",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { AsyncStorage, WebView } from "react-native";
2+
export const Browser = () => <WebView />;
3+
export const load = () => AsyncStorage.getItem("session");
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"private": true,
3+
"dependencies": {
4+
"react-native": "0.55.4"
5+
}
6+
}

‎packages/oxlint-plugin-react-doctor/src/plugin/constants/cross-file-rule-ids.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const CROSS_FILE_RULE_IDS: ReadonlySet<string> = new Set([
8383
"remotion-no-next-image",
8484
"rerender-memo-with-default-value",
8585
"server-sequential-independent-await",
86+
"rn-no-deprecated-modules",
8687
"rn-no-legacy-shadow-styles",
8788
"rn-no-raw-text",
8889
"rn-prefer-expo-image",

‎packages/oxlint-plugin-react-doctor/src/plugin/constants/react-native.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,11 @@ export const LEGACY_SHADOW_STYLE_PROPERTIES = new Set([
176176
"shadowRadius",
177177
"elevation",
178178
]);
179+
180+
export const REACT_NATIVE_MODULE_REMOVAL_MINOR: ReadonlyMap<string, number> = new Map([
181+
["WebView", 60],
182+
["AsyncStorage", 71],
183+
]);
184+
185+
export const REACT_NATIVE_SINGLE_MINOR_RANGE_PATTERN =
186+
/^[~^=v]?\s*0\.\d+(?:\.(?:\d+|[xX*]))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

‎packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,3 +879,20 @@ describe("collector registry", () => {
879879
).toEqual([...CROSS_FILE_RULE_IDS].sort());
880880
});
881881
});
882+
883+
describe("React Native export version dependencies", () => {
884+
it("records the installed native manifest on cold and warm collection", () => {
885+
writeFixtureFile("package.json", `{ "dependencies": { "react-native": "0.40.0" } }`);
886+
writeFixtureFile(
887+
"node_modules/react-native/package.json",
888+
`{ "name": "react-native", "version": "0.71.0" }`,
889+
);
890+
const appPath = writeFixtureFile("src/App.tsx", `import { AsyncStorage } from 'react-native';`);
891+
const coldTrace = collectFor(appPath, ["rn-no-deprecated-modules"]);
892+
const warmTrace = collectFor(appPath, ["rn-no-deprecated-modules"]);
893+
expect(coldTrace?.contentPaths.has(fixturePath("node_modules/react-native/package.json"))).toBe(
894+
true,
895+
);
896+
expect(warmTrace?.contentPaths).toEqual(coldTrace?.contentPaths);
897+
});
898+
});

‎packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolvePackageVersion } from "./utils/resolve-package-version.js";
12
import type { StaticImport } from "oxc-parser";
23
import { analyzeScopes } from "./semantic/scope-analysis.js";
34
import { awaitedStatementsMayShareWork } from "./utils/awaited-statements-may-share-work.js";
@@ -531,6 +532,12 @@ const collectLegacyArchDependencies: CrossFileDependencyCollector = ({ absoluteF
531532
isLegacyArchReactNativeFile(absoluteFilePath);
532533
};
533534

535+
const collectReactNativeVersionDependencies: CrossFileDependencyCollector = ({
536+
absoluteFilePath,
537+
}) => {
538+
resolvePackageVersion(absoluteFilePath, "react-native");
539+
};
540+
534541
const collectInkVersionDependencies: CrossFileDependencyCollector = ({ absoluteFilePath }) => {
535542
resolveInkVersion(absoluteFilePath);
536543
};
@@ -582,6 +589,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap<string, CrossFileDepe
582589
["rendering-hydration-no-flicker", collectEffectValueHelperDependencies],
583590
["rerender-memo-with-default-value", collectForwardedHookDependencies],
584591
["server-sequential-independent-await", collectSequentialAwaitDependencies],
592+
["rn-no-deprecated-modules", collectReactNativeVersionDependencies],
585593
["rn-no-legacy-shadow-styles", collectLegacyArchDependencies],
586594
["rn-no-raw-text", collectRnNoRawTextDependencies],
587595
["rn-prefer-expo-image", collectNearestManifestDependencies],

‎packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-deprecated-modules.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { DEPRECATED_RN_MODULE_REPLACEMENTS } from "../../constants/react-native.js";
1+
import { resolvePackageVersion } from "../../utils/resolve-package-version.js";
2+
import {
3+
DEPRECATED_RN_MODULE_REPLACEMENTS,
4+
REACT_NATIVE_MODULE_REMOVAL_MINOR,
5+
REACT_NATIVE_SINGLE_MINOR_RANGE_PATTERN,
6+
} from "../../constants/react-native.js";
27
import { defineRule } from "../../utils/define-rule.js";
38
import { isTypeOnlyImport } from "../../utils/is-type-only-import.js";
49
import type { RuleContext } from "../../utils/rule-context.js";
@@ -27,6 +32,17 @@ export const rnNoDeprecatedModules = defineRule({
2732

2833
const baseReplacement = DEPRECATED_RN_MODULE_REPLACEMENTS.get(importedName);
2934
if (!baseReplacement) continue;
35+
const removalMinor = REACT_NATIVE_MODULE_REMOVAL_MINOR.get(importedName);
36+
if (removalMinor !== undefined) {
37+
const resolvedVersion = resolvePackageVersion(context.filename, "react-native");
38+
if (
39+
resolvedVersion?.version.major === 0 &&
40+
resolvedVersion.version.minor < removalMinor &&
41+
(resolvedVersion.declaredRange === null ||
42+
REACT_NATIVE_SINGLE_MINOR_RANGE_PATTERN.test(resolvedVersion.declaredRange.trim()))
43+
)
44+
continue;
45+
}
3046

3147
context.report({
3248
node: specifier,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
5+
import { runRule } from "../../../test-utils/run-rule.js";
6+
import { resetManifestCaches } from "../../utils/read-nearest-package-manifest.js";
7+
import { rnNoDeprecatedModules } from "./rn-no-deprecated-modules.js";
8+
9+
let temporaryDirectory = "";
10+
const checkImport = (moduleName: string, declaredVersion: string, installedVersion?: string) => {
11+
fs.writeFileSync(
12+
path.join(temporaryDirectory, "package.json"),
13+
JSON.stringify({ dependencies: { "react-native": declaredVersion } }),
14+
);
15+
if (installedVersion) {
16+
const packageDirectory = path.join(temporaryDirectory, "node_modules", "react-native");
17+
fs.mkdirSync(packageDirectory, { recursive: true });
18+
fs.writeFileSync(
19+
path.join(packageDirectory, "package.json"),
20+
JSON.stringify({ name: "react-native", version: installedVersion }),
21+
);
22+
}
23+
return runRule(rnNoDeprecatedModules, `import { ${moduleName} } from 'react-native';`, {
24+
filename: path.join(temporaryDirectory, "app.tsx"),
25+
});
26+
};
27+
28+
describe("removed React Native export versions", () => {
29+
beforeEach(() => {
30+
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-exports-"));
31+
resetManifestCaches();
32+
});
33+
afterEach(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true }));
34+
35+
it.each([
36+
["AsyncStorage", "^0.40.0"],
37+
["AsyncStorage", "0.41.2"],
38+
["AsyncStorage", "0.50.3"],
39+
["AsyncStorage", "0.70.0"],
40+
["WebView", "0.55.4"],
41+
["WebView", "0.59.10"],
42+
["WebView", "~0.59.0"],
43+
["WebView", "0.59.x"],
44+
])("accepts %s before removal in %s", (moduleName, version) => {
45+
expect(checkImport(moduleName, version).diagnostics).toHaveLength(0);
46+
});
47+
48+
it.each([
49+
["AsyncStorage", "0.71.0"],
50+
["WebView", "0.60.0"],
51+
["WebView", ">=0.55.0"],
52+
["AsyncStorage", "0.55.0 || 0.71.0"],
53+
])("reports %s once removed in %s", (moduleName, version) => {
54+
expect(checkImport(moduleName, version).diagnostics).toHaveLength(1);
55+
});
56+
57+
it("uses the installed version instead of an older declaration", () => {
58+
expect(checkImport("AsyncStorage", "0.40.0", "0.71.0").diagnostics).toHaveLength(1);
59+
});
60+
it("uses the installed version instead of a newer declaration", () => {
61+
expect(checkImport("AsyncStorage", "0.71.0", "0.41.2").diagnostics).toHaveLength(0);
62+
});
63+
});

0 commit comments

Comments
 (0)