From 362c6bdb288827f0e82c632ed57ee124a595defc Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 14 Oct 2026 10:32:49 -0700 Subject: [PATCH 1/6] fix(hooks): allow readonly value-only state destructuring --- .changeset/quiet-omitted-state-setters.md | 7 + ...ok-use-state--actual-mismatched-setter.tsx | 10 + ...k-use-state--omitted-canonical-require.tsx | 10 + .../hook-use-state--omitted-snapshot.tsx | 11 + packages/fuzz/src/snippet-pools.ts | 1 + .../src/plugin/constants/react.ts | 3 + .../hook-use-state.omitted-setter.test.ts | 438 ++++++++++++++++++ .../rules/react-builtins/hook-use-state.ts | 40 +- .../has-react-default-provider-mutation.ts | 63 +++ .../utils/is-binding-assignment-target.ts | 36 ++ .../plugin/utils/is-member-mutation-target.ts | 38 ++ .../is-non-escaping-react-api-reference.ts | 14 + .../is-proven-unmodified-react-api-call.ts | 70 +++ .../is-unmodified-local-react-state-call.ts | 217 +++++++++ 14 files changed, 953 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-omitted-state-setters.md create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md new file mode 100644 index 0000000000..fcaf2a15a5 --- /dev/null +++ b/.changeset/quiet-omitted-state-setters.md @@ -0,0 +1,7 @@ +--- +"react-doctor": patch +"eslint-plugin-react-doctor": patch +"oxlint-plugin-react-doctor": patch +--- + +Allow immutable value-only useState tuples with omitted setters while preserving value-pattern and setter-naming checks. diff --git a/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx b/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx new file mode 100644 index 0000000000..09af9b8e08 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx @@ -0,0 +1,10 @@ +// rule: hook-use-state +// verdict: fail +// weakness: library-idiom +// source: synthetic actual setter naming remains enforced +import { useState } from "react"; + +export const useEditableMarker = () => { + const [editableMarker, replaceMarker] = useState("initial"); + return { editableMarker, replaceMarker }; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx b/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx new file mode 100644 index 0000000000..0d061a473a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx @@ -0,0 +1,10 @@ +// rule: hook-use-state +// verdict: pass +// weakness: library-idiom +// source: synthetic direct CommonJS provider +const React = require("react"); + +export const useRetainedKey = () => { + const [retainedKey] = React.useState("initial"); + return retainedKey; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx b/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx new file mode 100644 index 0000000000..9b747b427a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx @@ -0,0 +1,11 @@ +// rule: hook-use-state +// verdict: pass +// weakness: library-idiom +// source: synthetic immutable value-only tuple +import { useState } from "react"; + +export const useRetainedMarker = (initialMarker: string) => { + const [retainedMarker] = useState(initialMarker); + const [readRetainedMarker] = useState(() => () => retainedMarker); + return readRetainedMarker; +}; diff --git a/packages/fuzz/src/snippet-pools.ts b/packages/fuzz/src/snippet-pools.ts index accb163dd2..2b3d3a9082 100644 --- a/packages/fuzz/src/snippet-pools.ts +++ b/packages/fuzz/src/snippet-pools.ts @@ -153,6 +153,7 @@ export const EFFECT_SNIPPET_POOL = [ // State — lazy initializers (incl. SSR-hazardous localStorage/matchMedia), // toggles, loading triples, prop mirrors, reducers, ref-sync. export const STATE_SNIPPET_POOL = [ + `{ const [fuzzRetainedMarker] = useState(value); const [fuzzRetainedReader] = useState(() => () => fuzzRetainedMarker); const fuzzRetainedReaderResult = fuzzRetainedReader(); }`, `const fuzzValtioState = fuzzValtioProxy({ count: 0 }); const fuzzValtioSnapshot = useFuzzValtioSnapshot(fuzzValtioState); const handleFuzzValtioRead = () => console.log(fuzzValtioSnapshot.count); const fuzzValtioButton = ;`, `const FuzzArrayConsumer = memo(() => null); const FuzzArrayHost = () => ;`, `const FuzzEffectEventHost = ({ delay, onSearch }) => { useEffect(() => { const timeoutId = setTimeout(() => onSearch("done"), delay); return () => clearTimeout(timeoutId); }, [delay, onSearch]); return null; }; function FuzzTransitionHost() { const [isLoading, setIsLoading] = useState(false); const toggleLoading = () => setIsLoading(true); return ; } function FuzzMemoEarlyReturnHost({ loading }) { const content = useMemo(() => , []); if (loading) return null; return
{content}
; }`, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts index 0f45ea5560..83273abecb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -319,3 +319,6 @@ export const NAVIGATION_RECEIVER_NAMES = new Set([ export const NON_HOOK_REGISTRATION_APIS: ReadonlyMap> = new Map([ ["react_ujs", new Set(["useContext", "useContexts"])], ]); + +export const VALUE_ONLY_STATE_TUPLE_LENGTH = 1; +export const CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT = 1; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts new file mode 100644 index 0000000000..0e97225e8f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { hookUseState } from "./hook-use-state.js"; + +describe("hook-use-state omitted setters", () => { + it.each([ + [ + "constant initial number", + 'import {useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "prop snapshot", + 'import {useState} from "react"; const useSeed = (initialLabel) => {const [storedLabel] = useState(initialLabel); return storedLabel;};', + ], + [ + "eager allocation", + 'import {useState} from "react"; const [cacheHandle] = useState(new Map());', + ], + [ + "lazy allocation", + 'import {useState} from "react"; const [cacheHandle] = useState(() => new Map());', + ], + [ + "imported factory", + 'import {useState} from "react"; import {makeCache} from "./cache"; const [cacheHandle] = useState(makeCache);', + ], + [ + "returned function value", + 'import {useState} from "react"; const [fetchNext] = useState(() => () => 7);', + ], + [ + "promise value", + 'import {useState} from "react"; const [pendingResult] = useState(() => Promise.resolve(7));', + ], + ["default React import", 'import React from "react"; const [seededTotal] = React.useState(7);'], + [ + "namespace React import", + 'import * as React from "react"; const [seededTotal] = React.useState(7);', + ], + [ + "direct CommonJS provider", + 'const React = require("react"); const [seededTotal] = React.useState(7);', + ], + ["trailing comma", 'import {useState} from "react"; const [seededTotal,] = useState(7);'], + [ + "value member mutation", + 'import {useState} from "react"; const [cacheHandle] = useState(() => ({entry: 7})); cacheHandle.entry = 8;', + ], + [ + "hook metadata", + 'import {useState} from "react"; useState.description = "stable"; const [seededTotal] = useState(7);', + ], + [ + "provider metadata", + 'import React from "react"; React.description = "stable"; const [seededTotal] = React.useState(7);', + ], + [ + "member-pattern metadata", + 'import React from "react"; ({metadata: React.description} = source); const [seededTotal] = React.useState(7);', + ], + [ + "default RHS reads", + 'import React from "react"; ({callback: other = React.useState} = source); const [seededTotal] = React.useState(7);', + ], + [ + "value metadata pattern", + 'import {useState} from "react"; const [cacheHandle] = useState(() => ({})); ({entry: cacheHandle.entry} = source);', + ], + [ + "value default RHS read", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: other = seededTotal} = source);', + ], + ])("allows %s", (_name, code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + [ + "unresolved pattern computed key reads", + 'import React from "react"; ({[React.useState]: other} = source); const [seededTotal] = React.useState(7);', + ], + [ + "unresolved pattern value computed key read", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({[seededTotal]: other} = source);', + ], + ["uppercase value", 'import {useState} from "react"; const [TOTAL] = useState(7);'], + ["underscore value", 'import {useState} from "react"; const [_total] = useState(7);'], + ["top-level default", 'import {useState} from "react"; const [seededTotal = 7] = useState();'], + ["top-level rest", 'import {useState} from "react"; const [...retainedTuple] = useState(7);'], + ["empty tuple", 'import {useState} from "react"; const [] = useState(7);'], + [ + "object tuple binding", + 'import {useState} from "react"; const {0: seededTotal} = useState(7);', + ], + [ + "extra tuple slot", + 'import {useState} from "react"; const [seededTotal, setSeededTotal, extra] = useState(7);', + ], + [ + "wrong actual setter", + 'import {useState} from "react"; const [seededTotal, updateTotal] = useState(7);', + ], + [ + "underscore actual setter", + 'import {useState} from "react"; const [seededTotal, _setSeededTotal] = useState(7);', + ], + ["mutable declaration", 'import {useState} from "react"; let [seededTotal] = useState(7);'], + [ + "actual value reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); seededTotal = 8;', + ], + [ + "pattern binding reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: seededTotal} = source);', + ], + [ + "default pattern binding reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: seededTotal = 8} = source);', + ], + [ + "update binding", + 'import {useState} from "react"; const [seededTotal] = useState(7); seededTotal++;', + ], + [ + "for-of binding", + 'import {useState} from "react"; const [seededTotal] = useState(7); for (seededTotal of source) {}', + ], + [ + "shadowed hook", + 'import {useState} from "react"; const useSeed = (useState) => {const [seededTotal] = useState(7); return seededTotal;};', + ], + ["local hook spelling", "const useState = () => [7]; const [seededTotal] = useState(7);"], + ["unbound hook spelling", "const [seededTotal] = useState(7);"], + [ + "custom React provider", + "const React = {useState: () => [7]}; const [seededTotal] = React.useState(7);", + ], + [ + "type-only declaration", + 'import type {useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "type-only specifier", + 'import {type useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "hook binding replacement", + 'import {useState} from "react"; useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "hook function escape", + 'import {useState} from "react"; install(useState); const [seededTotal] = useState(7);', + ], + [ + "namespace hook replacement", + 'import React from "react"; React.useState = customHook; const [seededTotal] = React.useState(7);', + ], + [ + "sibling provider replacement", + 'import {useState} from "react"; import React from "react"; React.useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "provider escape", + 'import React from "react"; install(React); const [seededTotal] = React.useState(7);', + ], + [ + "provider method escape", + 'import React from "react"; install(React.useState); const [seededTotal] = React.useState(7);', + ], + [ + "provider Object.assign replacement", + 'import React from "react"; Object.assign(React, {useState: customHook}); const [seededTotal] = React.useState(7);', + ], + [ + "provider pattern replacement", + 'import React from "react"; ({callback: React.useState} = source); const [seededTotal] = React.useState(7);', + ], + [ + "provider default pattern replacement", + 'import React from "react"; ({callback: React.useState = customHook} = source); const [seededTotal] = React.useState(7);', + ], + [ + "provider for-of replacement", + 'import React from "react"; for (React.useState of source) {} const [seededTotal] = React.useState(7);', + ], + [ + "sibling namespace pattern replacement", + 'import {useState} from "react"; import * as Provider from "react"; ({callback: Provider.useState} = source); const [seededTotal] = useState(7);', + ], + [ + "CJS provider shadow", + 'const require = () => ({useState: () => [7]}); const React = require("react"); const [seededTotal] = React.useState(7);', + ], + [ + "CJS projected provider", + 'const React = require("react").custom; const [seededTotal] = React.useState(7);', + ], + [ + "CJS mutable provider", + 'let React = require("react"); const [seededTotal] = React.useState(7);', + ], + [ + "CJS escaped provider", + 'const React = require("react"); install(React); const [seededTotal] = React.useState(7);', + ], + [ + "CJS extra loader argument", + 'const React = require("react", options); const [seededTotal] = React.useState(7);', + ], + [ + "CJS deferred initialization", + 'const [seededTotal] = React.useState(7); const React = require("react");', + ], + [ + "hybrid CJS provider exposure", + 'import {useState} from "react"; const Provider = require("react"); Provider.useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "CJS pattern replacement", + 'const React = require("react"); ({callback: React.useState} = source); const [seededTotal] = React.useState(7);', + ], + ])("retains %s", (_name, code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const React = require("react"); with (provider) { const [seededTotal] = React.useState(7); }`, + `const React = require("react"); eval("React.useState = customHook"); const [seededTotal] = React.useState(7);`, + `require = customLoader; const React = require("react"); const [seededTotal] = React.useState(7);`, + `require.cache[require.resolve("react")] = {exports: customProvider}; const React = require("react"); const [seededTotal] = React.useState(7);`, + `const loader = require; const React = require("react"); const [seededTotal] = React.useState(7);`, + `install(require); const React = require("react"); const [seededTotal] = React.useState(7);`, + `require(moduleName); const React = require("react"); const [seededTotal] = React.useState(7);`, + `({loader: require} = loaders); const React = require("react"); const [seededTotal] = React.useState(7);`, + `for (require of loaders) {} const React = require("react"); const [seededTotal] = React.useState(7);`, + ])("keeps dynamic loader/provider scope unsupported: %s", (code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `module.require("react").useState = customHook;`, + `module["require"]("react").useState = customHook;`, + `module.parent.require("react").useState = customHook;`, + `const Module = require("module"); Module._load("react", module).useState = customHook;`, + `const Module = require("node:module"); const loader = Module.createRequire(__filename); loader("react").useState = customHook;`, + `const {createRequire} = require("module"); const loader = createRequire(__filename); loader("react").useState = customHook;`, + `process.mainModule.require("react").useState = customHook;`, + `process.getBuiltinModule("module")._load("react", module).useState = customHook;`, + ])("keeps visible native loader exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("keeps a second imported native loader unsupported", () => { + const result = runRule( + hookUseState, + `import React from "react"; import {createRequire} from "node:module"; const loader = createRequire(import.meta.url); loader("react").useState = customHook; const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const mode = process.env.NODE_ENV;`, + `const module = {require: () => ({})}; module.require("other");`, + `const process = {mainModule: {require: () => ({})}}; process.mainModule.require("other");`, + ])("preserves harmless process metadata and unrelated shadowed loaders: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `globalThis.eval('process.mainModule.require("react").useState = customHook');`, + `Function('process.mainModule.require("react").useState = customHook')();`, + `globalThis.Function('process.mainModule.require("react").useState = customHook')();`, + `const execute = (() => {}).constructor; execute('process.mainModule.require("react").useState = customHook')();`, + `const constructorKey = "constructor"; const execute = (() => {})[constructorKey]; execute('process.mainModule.require("react").useState = customHook')();`, + `const {constructor: execute} = () => {}; execute('process.mainModule.require("react").useState = customHook')();`, + ])("keeps dynamic provider execution unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const description = handler.constructor.name;`, + `const argumentCount = handler.constructor.length;`, + ])("allows immediate readonly constructor metadata: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `handler.constructor('process.mainModule.require("react").useState = customHook')();`, + `handler.constructor.bind(null)('process.mainModule.require("react").useState = customHook')();`, + `handler.constructor.name();`, + `handler.constructor.length = customHook;`, + ])("keeps callable or writable constructor exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("preserves an unrelated shadowed Function binding", () => { + const result = runRule( + hookUseState, + `const React = require("react"); const Function = () => () => null; Function("metadata")(); const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `const execute = Reflect.get(() => {}, "constructor"); execute('process.mainModule.require("react").useState = customHook')();`, + `const execute = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(() => {}), "constructor").value; execute('process.mainModule.require("react").useState = customHook')();`, + `const key = "process"; globalThis[key].mainModule.require("react").useState = customHook;`, + `const key = "require"; globalThis[key]("react").useState = customHook;`, + `const key = "Function"; globalThis[key]('process.mainModule.require("react").useState = customHook')();`, + `const key = "eval"; globalThis[key]('process.mainModule.require("react").useState = customHook');`, + `const getKey = () => "constructor"; const execute = (() => {})[getKey()]; execute('process.mainModule.require("react").useState = customHook')();`, + `const entries = {load: () => null}; const execute = entries[readKey()];`, + ])("keeps reflection and unknown computed exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("keeps a dynamic module mutation before a later component invocation unsupported", () => { + const result = runRule( + hookUseState, + `import React from "react"; const Panel = () => {const [seededTotal] = React.useState(7); return seededTotal;}; const initialize = async () => {const provider = await import("react"); provider.default.useState = customHook; return Panel();};`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const Object = {getPrototypeOf: () => null}; Object.getPrototypeOf("metadata");`, + `const Reflect = {get: () => null}; Reflect.get("metadata");`, + `const entries = [7]; const description = entries[0];`, + `const key = "description"; const entries = {description: "stable"}; const description = entries[key];`, + ])("allows shadowed reflection and static primitive keys: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `({[React.useState]: other} = source);`, + `const getKey = () => "constructor"; const {[getKey()]: execute} = () => {}; execute('process.mainModule.require("react").useState = () => 7')();`, + ])("keeps unresolved computed pattern exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const metadataKey = "description"; ({[metadataKey]: other} = source);`, + `({"description": other = React.useState} = source);`, + ])("allows known primitive pattern keys and default reads: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it("allows other ordinary literal CommonJS loads", () => { + const result = runRule( + hookUseState, + `const React = require("react"); const renderer = require("react-dom"); const widget = require("./widget"); const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `React.description = "stable";`, + `({metadata: React.description} = source);`, + `({callback: other = React.useState} = source);`, + ])("allows CommonJS metadata and non-target reads: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `const [{item}] = useState(() => ({item: 7}));`, + `const [[item]] = useState(() => [7]);`, + ])("uses the explicit nested-value setting: %s", (declaration) => { + const code = `import {useState} from "react"; ${declaration}`; + expect(runRule(hookUseState, code).diagnostics).toHaveLength(1); + expect( + runRule(hookUseState, code, { + settings: { "react-doctor": { hookUseState: { allowDestructuredState: true } } }, + }).diagnostics, + ).toHaveLength(0); + }); + it("still rejects writes to explicitly allowed nested bindings", () => { + const result = runRule( + hookUseState, + `import {useState} from "react"; const [{item}] = useState(() => ({item: 7})); ({item} = source);`, + { settings: { "react-doctor": { hookUseState: { allowDestructuredState: true } } } }, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `import {useState} from "react"; const [seededTotal,,] = useState(7);`, + `import {useState} from "react"; const [,setSeededTotal] = useState(7);`, + `import {useState as retain} from "react"; const [seededTotal] = retain(7);`, + `import {useState} from "react"; const useSeedTuple = () => useState(7);`, + ])("preserves existing matcher and hole paths: %s", (code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts index 3e713abd4f..723537f028 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts @@ -1,9 +1,13 @@ +import { VALUE_ONLY_STATE_TUPLE_LENGTH } from "../../constants/react.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { findTransparentExpressionRoot } from "../../utils/find-transparent-expression-root.js"; +import { isBindingAssignmentTarget } from "../../utils/is-binding-assignment-target.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { isReactFunctionCall } from "../../utils/is-react-function-call.js"; +import { isUnmodifiedLocalReactStateCall } from "../../utils/is-unmodified-local-react-state-call.js"; +import { walkAst } from "../../utils/walk-ast.js"; const REQUIRE_DESTRUCTURE_MESSAGE = "`useState` should be destructured as `[value, setValue]` so readers can see the state value and setter together."; @@ -66,11 +70,6 @@ export const hookUseState = defineRule({ id: "hook-use-state", title: "useState not destructured", severity: "warn", - // Stylistic naming rule — flags `const [count, _setCount]` (the - // unused-marker underscore convention) and `const [instance] = - // useState(() => new Foo())` (the create-once initializer pattern - // where the setter is intentionally never used). Both are - // idiomatic. Default off. defaultEnabled: false, recommendation: "Destructure `useState` as `const [thing, setThing] = useState(…)` so state reads and writes stay visible together.", @@ -102,6 +101,37 @@ export const hookUseState = defineRule({ context.report({ node: parent, message: REQUIRE_DESTRUCTURE_MESSAGE }); return; } + if (idPattern.elements.length === VALUE_ONLY_STATE_TUPLE_LENGTH) { + const valueElement = idPattern.elements[0]; + const hasAllowedValue = Boolean( + valueElement && + ((isNodeOfType(valueElement, "Identifier") && + splitLeadingLowercase(valueElement.name)) || + (allowDestructuredState && isDestructurePattern(valueElement))), + ); + let hasBindingWrite = false; + walkAst(idPattern, (child) => { + if (!isNodeOfType(child, "Identifier")) return; + const symbol = context.scopes.symbolFor(child); + if (symbol?.bindingIdentifier !== child || symbol.declarationNode !== parent) return; + if ( + symbol.references.some( + (reference) => + reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier), + ) + ) { + hasBindingWrite = true; + } + }); + if ( + hasAllowedValue && + !hasBindingWrite && + isNodeOfType(parent.parent, "VariableDeclaration") && + parent.parent.kind === "const" && + isUnmodifiedLocalReactStateCall(node, context.scopes) + ) + return; + } if (idPattern.elements.length !== 2) { context.report({ node: idPattern, message: REQUIRE_DESTRUCTURE_MESSAGE }); return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts new file mode 100644 index 0000000000..cc5b41909f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts @@ -0,0 +1,63 @@ +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getImportDeclarationForSymbol } from "./get-import-declaration-for-symbol.js"; +import { getImportedName } from "./get-imported-name.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +const isDefaultImport = (symbol: SymbolDescriptor): boolean => + isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || + getImportedName(symbol.declarationNode) === "default"; + +const isReactImport = (symbol: SymbolDescriptor): boolean => { + const declaration = getImportDeclarationForSymbol(symbol); + return Boolean( + declaration && + declaration.source.value === "react" && + declaration.importKind !== "type" && + (!isNodeOfType(symbol.declarationNode, "ImportSpecifier") || + symbol.declarationNode.importKind !== "type"), + ); +}; + +export const hasReactDefaultProviderMutation = ( + propertyName: string, + scopes: ScopeAnalysis, +): boolean => { + return scopes.rootScope.symbols.some((symbol) => { + if (!isReactImport(symbol)) return false; + const isNamespaceImport = isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier"); + if (!isDefaultImport(symbol) && !isNamespaceImport) return false; + if (hasPossibleStaticPropertyMutationOrEscape(symbol.bindingIdentifier, propertyName, scopes)) { + return true; + } + return getEquivalentSymbols(symbol.bindingIdentifier, scopes).some((alias) => + alias.references.some((reference) => { + if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { + return true; + } + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return !( + isNodeOfType(member, "VariableDeclarator") && + member.init === referenceRoot && + isNodeOfType(member.id, "Identifier") && + isNodeOfType(member.parent, "VariableDeclaration") && + member.parent.kind === "const" + ); + } + const referencedProperty = getStaticPropertyName(member); + return ( + referencedProperty === null || + (isNamespaceImport && referencedProperty === "default") || + (referencedProperty === propertyName && !isNonEscapingReactApiReference(member)) + ); + }), + ); + }); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts new file mode 100644 index 0000000000..c2715d0893 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts @@ -0,0 +1,36 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isBindingAssignmentTarget = (identifier: EsTreeNode): boolean => { + if (!isNodeOfType(identifier, "Identifier")) return false; + let currentNode = findTransparentExpressionRoot(identifier); + while (currentNode.parent) { + const parentNode = currentNode.parent; + if (isNodeOfType(parentNode, "AssignmentExpression")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "UpdateExpression")) { + return parentNode.argument === currentNode; + } + if (isNodeOfType(parentNode, "ForInStatement") || isNodeOfType(parentNode, "ForOfStatement")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "Property")) { + if (parentNode.value !== currentNode || !isNodeOfType(parentNode.parent, "ObjectPattern")) { + return false; + } + } else if (isNodeOfType(parentNode, "AssignmentPattern")) { + if (parentNode.left !== currentNode) return false; + } else if (isNodeOfType(parentNode, "RestElement")) { + if (parentNode.argument !== currentNode) return false; + } else if ( + !isNodeOfType(parentNode, "ArrayPattern") && + !isNodeOfType(parentNode, "ObjectPattern") + ) { + return false; + } + currentNode = findTransparentExpressionRoot(parentNode); + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts new file mode 100644 index 0000000000..6d2fade0ee --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts @@ -0,0 +1,38 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isMemberMutationTarget = (memberExpression: EsTreeNode): boolean => { + let currentNode = findTransparentExpressionRoot(memberExpression); + while (currentNode.parent) { + const parentNode = currentNode.parent; + if (isNodeOfType(parentNode, "AssignmentExpression")) { + return parentNode.left === currentNode; + } + if ( + isNodeOfType(parentNode, "UpdateExpression") || + (isNodeOfType(parentNode, "UnaryExpression") && parentNode.operator === "delete") + ) { + return parentNode.argument === currentNode; + } + if (isNodeOfType(parentNode, "ForInStatement") || isNodeOfType(parentNode, "ForOfStatement")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "Property")) { + if (parentNode.value !== currentNode || !isNodeOfType(parentNode.parent, "ObjectPattern")) { + return false; + } + } else if (isNodeOfType(parentNode, "AssignmentPattern")) { + if (parentNode.left !== currentNode) return false; + } else if (isNodeOfType(parentNode, "RestElement")) { + if (parentNode.argument !== currentNode) return false; + } else if ( + !isNodeOfType(parentNode, "ArrayPattern") && + !isNodeOfType(parentNode, "ObjectPattern") + ) { + return false; + } + currentNode = findTransparentExpressionRoot(parentNode); + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts new file mode 100644 index 0000000000..4ee0d55702 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts @@ -0,0 +1,14 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isNonEscapingReactApiReference = (expression: EsTreeNode): boolean => { + const referenceRoot = findTransparentExpressionRoot(expression); + const parent = referenceRoot.parent; + return ( + (isNodeOfType(parent, "CallExpression") && parent.callee === referenceRoot) || + (isNodeOfType(parent, "MemberExpression") && parent.object === referenceRoot) || + (isNodeOfType(parent, "Property") && parent.computed && parent.key === referenceRoot) || + (isNodeOfType(parent, "AssignmentPattern") && parent.right === referenceRoot) + ); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts new file mode 100644 index 0000000000..7710ba0af3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts @@ -0,0 +1,70 @@ +import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isReactApiCall } from "./is-react-api-call.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; + +export const isProvenUnmodifiedReactApiCall = ( + call: EsTreeNode, + apiNames: string | ReadonlySet, + scopes: ScopeAnalysis, +): boolean => { + const invocation = stripParenExpression(call); + if ( + !isNodeOfType(invocation, "CallExpression") || + !isReactApiCall(invocation, apiNames, scopes) + ) { + return false; + } + const callee = stripParenExpression(invocation.callee); + const identifier = getRootIdentifier(callee); + const symbol = identifier ? scopes.symbolFor(identifier) : null; + if ( + !identifier || + symbol?.kind !== "import" || + symbol.references.some( + (reference) => reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier), + ) + ) { + return false; + } + if (isNodeOfType(callee, "MemberExpression")) { + const propertyName = getStaticPropertyName(callee); + if ( + !propertyName || + hasPossibleStaticPropertyMutationOrEscape(identifier, propertyName, scopes) + ) { + return false; + } + return getEquivalentSymbols(identifier, scopes).every((namespaceSymbol) => + namespaceSymbol.references.every((reference) => { + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return ( + isNodeOfType(member, "VariableDeclarator") && + member.init === referenceRoot && + isNodeOfType(member.id, "Identifier") && + isNodeOfType(member.parent, "VariableDeclaration") && + member.parent.kind === "const" + ); + } + const referencedProperty = getStaticPropertyName(member); + return ( + (referencedProperty !== null && referencedProperty !== propertyName) || + isNonEscapingReactApiReference(member) + ); + }), + ); + } + return symbol.references.every((reference) => + isNonEscapingReactApiReference(reference.identifier), + ); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts new file mode 100644 index 0000000000..142ace47b4 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts @@ -0,0 +1,217 @@ +import { CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT } from "../constants/react.js"; +import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getGlobalRequireModuleSource } from "./get-global-require-module-source.js"; +import { getImportDeclarationForSymbol } from "./get-import-declaration-for-symbol.js"; +import { getRuntimeStaticDependencySource } from "./get-runtime-static-dependency-source.js"; +import { + isProvenGlobalNamespaceReference, + isProvenGlobalObjectReference, +} from "./is-proven-global-namespace-reference.js"; +import { getResolvedStaticPropertyName } from "./get-resolved-static-property-name.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasReactDefaultProviderMutation } from "./has-react-default-provider-mutation.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isMemberMutationTarget } from "./is-member-mutation-target.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isProvenUnmodifiedReactApiCall } from "./is-proven-unmodified-react-api-call.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; +import { walkAst } from "./walk-ast.js"; + +export const isUnmodifiedLocalReactStateCall = ( + call: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + if (!isNodeOfType(call, "CallExpression")) return false; + const callee = stripParenExpression(call.callee); + const receiver = getRootIdentifier(callee); + const symbol = receiver ? scopes.symbolFor(receiver) : null; + if (!receiver || !symbol) return false; + const importDeclaration = getImportDeclarationForSymbol(symbol); + let allowedRequire: EsTreeNode | null = null; + if (importDeclaration) { + if ( + importDeclaration.importKind === "type" || + (isNodeOfType(symbol.declarationNode, "ImportSpecifier") && + symbol.declarationNode.importKind === "type") || + !isProvenUnmodifiedReactApiCall(call, "useState", scopes) + ) { + return false; + } + } else { + const declaration = symbol.declarationNode; + const initializer = symbol.initializer ? stripParenExpression(symbol.initializer) : null; + if ( + !isNodeOfType(callee, "MemberExpression") || + stripParenExpression(callee.object) !== receiver || + getStaticPropertyName(callee) !== "useState" || + symbol.kind !== "const" || + !isNodeOfType(declaration, "VariableDeclarator") || + declaration.id !== symbol.bindingIdentifier || + !isNodeOfType(initializer, "CallExpression") || + initializer.arguments.length !== CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT || + initializer.optional || + initializer.range[1] > call.range[0] || + getGlobalRequireModuleSource(initializer, scopes) !== "react" || + symbol.references.some((reference) => { + if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { + return true; + } + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return true; + } + const propertyName = getStaticPropertyName(member); + return ( + propertyName === null || + propertyName === "default" || + (propertyName === "useState" && !isNonEscapingReactApiReference(member)) + ); + }) || + hasPossibleStaticPropertyMutationOrEscape(receiver, "useState", scopes) + ) { + return false; + } + allowedRequire = initializer; + } + if (hasReactDefaultProviderMutation("useState", scopes)) return false; + const providerSymbolIds = new Set(); + for (const provider of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(provider); + if (declaration?.source.value !== "react") continue; + for (const equivalent of getEquivalentSymbols(provider.bindingIdentifier, scopes)) { + providerSymbolIds.add(equivalent.id); + } + } + if (allowedRequire) providerSymbolIds.add(symbol.id); + let hasProviderHazard = false; + walkAst(scopes.rootScope.node, (child) => { + if (hasProviderHazard) return false; + const dependency = + getRuntimeStaticDependencySource(child) ?? getGlobalRequireModuleSource(child, scopes); + if ( + dependency === "module" || + dependency === "node:module" || + isProvenGlobalNamespaceReference(child, "module", scopes) + ) { + hasProviderHazard = true; + } + if (isProvenGlobalNamespaceReference(child, "process", scopes)) { + const referenceRoot = findTransparentExpressionRoot(child); + const member = referenceRoot.parent; + if ( + !isNodeOfType(member, "MemberExpression") || + member.object !== referenceRoot || + getStaticPropertyName(member) !== "env" || + isMemberMutationTarget(member) + ) { + hasProviderHazard = true; + } + } + if ( + isNodeOfType(child, "WithStatement") || + isNodeOfType(child, "ImportExpression") || + isProvenGlobalNamespaceReference(child, "Function", scopes) || + isProvenGlobalNamespaceReference(child, "eval", scopes) || + isProvenGlobalNamespaceReference(child, "Object", scopes) || + isProvenGlobalNamespaceReference(child, "Reflect", scopes) + ) { + hasProviderHazard = true; + } + if (isProvenGlobalObjectReference(child, scopes)) { + const referenceRoot = findTransparentExpressionRoot(child); + const member = referenceRoot.parent; + const propertyName = isNodeOfType(member, "MemberExpression") + ? getResolvedStaticPropertyName(member, scopes) + : null; + if ( + propertyName === null || + propertyName === "Function" || + propertyName === "eval" || + propertyName === "Object" || + propertyName === "Reflect" || + propertyName === "module" || + propertyName === "process" || + propertyName === "require" + ) { + hasProviderHazard = true; + } + } + if ( + (isNodeOfType(child, "MemberExpression") || + (isNodeOfType(child, "Property") && isNodeOfType(child.parent, "ObjectPattern"))) && + child.computed && + getResolvedStaticPropertyName(child, scopes, { + allowConstNumericLiteral: true, + allowConstTemplateLiteral: true, + stringifyNonStringLiterals: true, + }) === null + ) { + hasProviderHazard = true; + } + if ( + (isNodeOfType(child, "MemberExpression") || + (isNodeOfType(child, "Property") && isNodeOfType(child.parent, "ObjectPattern"))) && + getResolvedStaticPropertyName(child, scopes) === "constructor" + ) { + const constructorRoot = findTransparentExpressionRoot(child); + const metadata = constructorRoot.parent; + const metadataRoot = isNodeOfType(metadata, "MemberExpression") + ? findTransparentExpressionRoot(metadata) + : null; + const consumer = metadataRoot?.parent; + if ( + !isNodeOfType(child, "MemberExpression") || + !isNodeOfType(metadata, "MemberExpression") || + metadata.object !== constructorRoot || + (getStaticPropertyName(metadata) !== "name" && + getStaticPropertyName(metadata) !== "length") || + isMemberMutationTarget(metadata) || + (isNodeOfType(consumer, "CallExpression") && consumer.callee === metadataRoot) + ) { + hasProviderHazard = true; + } + } + if (isNodeOfType(child, "Identifier") && scopes.isGlobalReference(child)) { + if (child.name === "require") { + const referenceRoot = findTransparentExpressionRoot(child); + const invocation = referenceRoot.parent; + if ( + !isNodeOfType(invocation, "CallExpression") || + invocation.callee !== referenceRoot || + invocation.optional || + invocation.arguments.length !== CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT || + !isNodeOfType(invocation.arguments[0], "Literal") || + typeof invocation.arguments[0].value !== "string" + ) { + hasProviderHazard = true; + } + } + } + if ( + isNodeOfType(child, "CallExpression") && + child !== allowedRequire && + getGlobalRequireModuleSource(child, scopes) === "react" + ) { + hasProviderHazard = true; + } + if (!isNodeOfType(child, "MemberExpression") || !isMemberMutationTarget(child)) return; + const identifier = getRootIdentifier(child.object); + const provider = identifier ? scopes.symbolFor(identifier) : null; + const propertyName = getStaticPropertyName(child); + if ( + provider && + providerSymbolIds.has(provider.id) && + (propertyName === null || propertyName === "useState") + ) { + hasProviderHazard = true; + } + }); + return !hasProviderHazard; +}; From aab8e6dd1c4b497adbbad537aafcb3b6ae7b8663 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 16 Oct 2026 19:36:17 -0700 Subject: [PATCH 2/6] fix: preserve provider hazards through erased TypeScript bindings --- .changeset/quiet-omitted-state-setters.md | 2 ++ ...hook-use-state--erased-loader-provider.tsx | 13 +++++++ .../hook-use-state.omitted-setter.test.ts | 32 +++++++++++++++++ .../is-erased-typescript-binding.test.ts | 35 +++++++++++++++++++ .../utils/is-erased-typescript-binding.ts | 13 +++++++ .../is-unmodified-local-react-state-call.ts | 22 ++++++++++++ 6 files changed, 117 insertions(+) create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md index fcaf2a15a5..3867cd6d71 100644 --- a/.changeset/quiet-omitted-state-setters.md +++ b/.changeset/quiet-omitted-state-setters.md @@ -5,3 +5,5 @@ --- Allow immutable value-only useState tuples with omitted setters while preserving value-pattern and setter-naming checks. + +Keep runtime loader and provider exposure unsupported when TypeScript-only declarations erase their apparent local bindings. diff --git a/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx new file mode 100644 index 0000000000..d14575417b --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic erased TypeScript loader binding +import { useState } from "react"; + +declare const require: (source: string) => { useState: unknown }; +require("react").useState = () => [7, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts index 0e97225e8f..4e182bdd57 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts @@ -384,6 +384,38 @@ describe("hook-use-state omitted setters", () => { expect(result.parseErrors).toEqual([]); expect(result.diagnostics).toHaveLength(0); }); + it.each([ + `declare const require: any; require("react").useState = customHook;`, + `declare function require(source: string): any; require("react").useState = customHook;`, + `interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, + `type Reflect = unknown; Reflect.set(provider, "useState", customHook);`, + `import type {Function} from "./types"; Function("replaceProvider()")();`, + `import {type Object} from "./types"; Object.defineProperty(provider, "useState", {value: customHook});`, + `declare const globalThis: any; globalThis.require("react").useState = customHook;`, + `declare const module: any; module.require("react").useState = customHook;`, + `declare const process: any; process.mainModule.require("react").useState = customHook;`, + ])("retains diagnostics through erased provider bindings: %s", (exposure) => { + const result = runRule( + hookUseState, + `import {useState} from "react"; ${exposure} const [seededTotal] = useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `declare const require: any;`, + `interface Object { description: string; }`, + `type Reflect = unknown;`, + `import type {Function} from "./types";`, + `const require = () => ({}); require("metadata");`, + ])("allows unused erased declarations and real local loaders: %s", (metadata) => { + const result = runRule( + hookUseState, + `import {useState} from "react"; ${metadata} const [seededTotal] = useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); it("allows other ordinary literal CommonJS loads", () => { const result = runRule( hookUseState, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts new file mode 100644 index 0000000000..2d3c1ae86c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; + +describe("isErasedTypeScriptBinding", () => { + it.each([ + { code: "declare const candidate: unknown;", expected: true }, + { code: "declare let candidate: unknown;", expected: true }, + { code: "declare var candidate: unknown;", expected: true }, + { code: "declare class candidate {}", expected: true }, + { code: "export declare const candidate: unknown;", expected: true }, + { code: "interface candidate {}", expected: true }, + { code: "type candidate = unknown;", expected: true }, + { code: 'import type candidate from "provider";', expected: true }, + { code: 'import type * as candidate from "provider";', expected: true }, + { code: 'import { type candidate } from "provider";', expected: true }, + { code: "const candidate = 0;", expected: false }, + { code: "let candidate = 0;", expected: false }, + { code: "var candidate = 0;", expected: false }, + { code: "class candidate {}", expected: false }, + { code: "function candidate() {}", expected: false }, + { code: 'import { candidate } from "provider";', expected: false }, + { code: 'import candidate from "provider";', expected: false }, + { code: 'import * as candidate from "provider";', expected: false }, + ])("classifies $code", ({ code, expected }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const symbol = scopes.rootScope.symbols.find((binding) => binding.name === "candidate"); + expect(symbol && isErasedTypeScriptBinding(symbol)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts new file mode 100644 index 0000000000..3f59999e99 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts @@ -0,0 +1,13 @@ +import type { SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { getValueImportDeclaration } from "./get-value-import-declaration.js"; + +export const isErasedTypeScriptBinding = (symbol: SymbolDescriptor): boolean => { + if (symbol.kind === "ts-interface" || symbol.kind === "ts-type-alias") return true; + if (symbol.kind === "import") return getValueImportDeclaration(symbol) === null; + let declaration = symbol.declarationNode; + while (declaration.parent) { + if ("declare" in declaration && declaration.declare === true) return true; + declaration = declaration.parent; + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts index 142ace47b4..2dd3088012 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts @@ -16,6 +16,7 @@ import { getStaticPropertyName } from "./get-static-property-name.js"; import { hasReactDefaultProviderMutation } from "./has-react-default-provider-mutation.js"; import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; import { isMemberMutationTarget } from "./is-member-mutation-target.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; @@ -23,6 +24,20 @@ import { isProvenUnmodifiedReactApiCall } from "./is-proven-unmodified-react-api import { stripParenExpression } from "./strip-paren-expression.js"; import { walkAst } from "./walk-ast.js"; +const PROVIDER_GLOBAL_NAMES = new Set([ + "require", + "module", + "process", + "Function", + "eval", + "Object", + "Reflect", + "globalThis", + "global", + "window", + "self", +]); + export const isUnmodifiedLocalReactStateCall = ( call: EsTreeNode, scopes: ScopeAnalysis, @@ -93,6 +108,13 @@ export const isUnmodifiedLocalReactStateCall = ( let hasProviderHazard = false; walkAst(scopes.rootScope.node, (child) => { if (hasProviderHazard) return false; + if (isNodeOfType(child, "Identifier") && PROVIDER_GLOBAL_NAMES.has(child.name)) { + const binding = scopes.symbolFor(child); + if (binding && scopes.referenceFor(child) && isErasedTypeScriptBinding(binding)) { + hasProviderHazard = true; + return false; + } + } const dependency = getRuntimeStaticDependencySource(child) ?? getGlobalRequireModuleSource(child, scopes); if ( From fd587aa80a92d6d7a5d8b0833484ee17c411ee9e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 16 Oct 2026 20:10:51 -0700 Subject: [PATCH 3/6] fix: preserve runtime values through erased provider bindings --- .changeset/quiet-omitted-state-setters.md | 2 + ...hook-use-state--local-loader-interface.tsx | 14 +++++ ...use-state--type-import-equals-provider.tsx | 13 ++++ .../hook-use-state.omitted-setter.test.ts | 5 ++ .../utils/has-runtime-value-binding.test.ts | 60 +++++++++++++++++++ .../plugin/utils/has-runtime-value-binding.ts | 23 +++++++ .../is-erased-typescript-binding.test.ts | 2 + .../utils/is-erased-typescript-binding.ts | 6 ++ .../is-unmodified-local-react-state-call.ts | 8 ++- 9 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md index 3867cd6d71..e86ffeccb3 100644 --- a/.changeset/quiet-omitted-state-setters.md +++ b/.changeset/quiet-omitted-state-setters.md @@ -7,3 +7,5 @@ Allow immutable value-only useState tuples with omitted setters while preserving value-pattern and setter-naming checks. Keep runtime loader and provider exposure unsupported when TypeScript-only declarations erase their apparent local bindings. + +Recognize erased type-only import equals bindings while preserving genuine local runtime values merged with interfaces. diff --git a/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx b/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx new file mode 100644 index 0000000000..05aa1c60cf --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx @@ -0,0 +1,14 @@ +// rule: hook-use-state +// verdict: pass +// weakness: alias-guard +// source: synthetic local provider binding merged with an interface +import { useState } from "react"; + +const require = () => ({ useState: () => [7, () => {}] }); +interface require {} +require("react").useState = () => [9, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx new file mode 100644 index 0000000000..b5179f2f90 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic type-only import equals provider binding +import { useState } from "react"; +import type require = require("./provider-types"); + +require("react").useState = () => [7, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts index 4e182bdd57..0dbfd7c184 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts @@ -386,11 +386,13 @@ describe("hook-use-state omitted setters", () => { }); it.each([ `declare const require: any; require("react").useState = customHook;`, + `import type require = require("./types"); require("react").useState = customHook;`, `declare function require(source: string): any; require("react").useState = customHook;`, `interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, `type Reflect = unknown; Reflect.set(provider, "useState", customHook);`, `import type {Function} from "./types"; Function("replaceProvider()")();`, `import {type Object} from "./types"; Object.defineProperty(provider, "useState", {value: customHook});`, + `import type Object = require("./types"); Object.defineProperty(provider, "useState", {value: customHook});`, `declare const globalThis: any; globalThis.require("react").useState = customHook;`, `declare const module: any; module.require("react").useState = customHook;`, `declare const process: any; process.mainModule.require("react").useState = customHook;`, @@ -408,6 +410,9 @@ describe("hook-use-state omitted setters", () => { `type Reflect = unknown;`, `import type {Function} from "./types";`, `const require = () => ({}); require("metadata");`, + `const require = () => ({useState: customHook}); interface require {} require("react").useState = customHook;`, + `interface require {} const require = () => ({useState: customHook}); require("react").useState = customHook;`, + `const Object = {defineProperty() {}}; interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, ])("allows unused erased declarations and real local loaders: %s", (metadata) => { const result = runRule( hookUseState, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts new file mode 100644 index 0000000000..75a619093f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { hasRuntimeValueBinding } from "./has-runtime-value-binding.js"; + +describe("hasRuntimeValueBinding", () => { + it.each([ + { code: "interface candidate {}", expected: false, nested: false }, + { code: "type candidate = unknown;", expected: false, nested: false }, + { code: "declare const candidate: unknown;", expected: false, nested: false }, + { code: "declare class candidate {}", expected: false, nested: false }, + { code: 'import type candidate from "provider";', expected: false, nested: false }, + { code: 'import type candidate = require("provider");', expected: false, nested: false }, + { code: "const candidate = 0;", expected: true, nested: false }, + { code: "let candidate = 0;", expected: true, nested: false }, + { code: "var candidate = 0;", expected: true, nested: false }, + { code: "const candidate = 0; interface candidate {}", expected: true, nested: false }, + { code: "interface candidate {} const candidate = 0;", expected: true, nested: false }, + { code: "const candidate = 0; type candidate = unknown;", expected: true, nested: false }, + { code: "class candidate {} interface candidate {}", expected: true, nested: false }, + { code: "function candidate() {} interface candidate {}", expected: true, nested: false }, + { code: 'import candidate from "provider";', expected: true, nested: false }, + { code: 'import candidate = require("provider");', expected: true, nested: false }, + { + code: "const candidate = 0; function owner() { interface candidate {} }", + expected: true, + nested: true, + }, + { + code: "interface candidate {} function owner() { const candidate = 0; }", + expected: false, + nested: false, + }, + { + code: "declare const candidate: unknown; function owner() { interface candidate {} }", + expected: false, + nested: true, + }, + { + code: "function owner(candidate: unknown) { interface candidate {} }", + expected: true, + nested: true, + }, + { code: "const unrelated = 0; interface candidate {}", expected: false, nested: false }, + ])("resolves $code", ({ code, expected, nested }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const bindingScope = nested + ? scopes.rootScope.children.find((scope) => scope.kind === "function") + : scopes.rootScope; + const symbol = bindingScope?.symbols.findLast((binding) => binding.name === "candidate"); + expect(symbol).toBeDefined(); + if (!symbol) throw new Error("Missing fixture binding"); + expect(hasRuntimeValueBinding(symbol)).toBe(expected); + expect(hasRuntimeValueBinding(symbol)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts new file mode 100644 index 0000000000..0fdd76042e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts @@ -0,0 +1,23 @@ +import type { ScopeDescriptor, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; + +const runtimeValueBindings = new WeakMap(); + +export const hasRuntimeValueBinding = (symbol: SymbolDescriptor): boolean => { + const cached = runtimeValueBindings.get(symbol); + if (cached !== undefined) return cached; + let scope: ScopeDescriptor | null = symbol.scope; + while (scope) { + if ( + scope.symbols.some( + (candidate) => candidate.name === symbol.name && !isErasedTypeScriptBinding(candidate), + ) + ) { + runtimeValueBindings.set(symbol, true); + return true; + } + scope = scope.parent; + } + runtimeValueBindings.set(symbol, false); + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts index 2d3c1ae86c..7b23779894 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts @@ -16,6 +16,8 @@ describe("isErasedTypeScriptBinding", () => { { code: 'import type candidate from "provider";', expected: true }, { code: 'import type * as candidate from "provider";', expected: true }, { code: 'import { type candidate } from "provider";', expected: true }, + { code: 'import type candidate = require("provider");', expected: true }, + { code: 'import candidate = require("provider");', expected: false }, { code: "const candidate = 0;", expected: false }, { code: "let candidate = 0;", expected: false }, { code: "var candidate = 0;", expected: false }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts index 3f59999e99..19eaed1bdb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts @@ -4,6 +4,12 @@ import { getValueImportDeclaration } from "./get-value-import-declaration.js"; export const isErasedTypeScriptBinding = (symbol: SymbolDescriptor): boolean => { if (symbol.kind === "ts-interface" || symbol.kind === "ts-type-alias") return true; if (symbol.kind === "import") return getValueImportDeclaration(symbol) === null; + if ( + symbol.kind === "ts-import-equals" && + "importKind" in symbol.declarationNode && + symbol.declarationNode.importKind === "type" + ) + return true; let declaration = symbol.declarationNode; while (declaration.parent) { if ("declare" in declaration && declaration.declare === true) return true; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts index 2dd3088012..e9839ba8cc 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts @@ -16,6 +16,7 @@ import { getStaticPropertyName } from "./get-static-property-name.js"; import { hasReactDefaultProviderMutation } from "./has-react-default-provider-mutation.js"; import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { hasRuntimeValueBinding } from "./has-runtime-value-binding.js"; import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; import { isMemberMutationTarget } from "./is-member-mutation-target.js"; import { isNodeOfType } from "./is-node-of-type.js"; @@ -110,7 +111,12 @@ export const isUnmodifiedLocalReactStateCall = ( if (hasProviderHazard) return false; if (isNodeOfType(child, "Identifier") && PROVIDER_GLOBAL_NAMES.has(child.name)) { const binding = scopes.symbolFor(child); - if (binding && scopes.referenceFor(child) && isErasedTypeScriptBinding(binding)) { + if ( + binding && + scopes.referenceFor(child) && + isErasedTypeScriptBinding(binding) && + !hasRuntimeValueBinding(binding) + ) { hasProviderHazard = true; return false; } From cec67b341c9a2d2b036f21668e6e32d6be6e9fed Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 16 Oct 2026 20:25:42 -0700 Subject: [PATCH 4/6] fix: retain provider proof through erased namespaces --- .changeset/quiet-omitted-state-setters.md | 2 + ...-use-state--runtime-namespace-provider.tsx | 16 ++++++ ...se-state--type-only-namespace-provider.tsx | 15 ++++++ .../hook-use-state.omitted-setter.test.ts | 7 +++ .../utils/has-runtime-value-binding.test.ts | 51 +++++++++++++++++++ .../is-erased-typescript-binding.test.ts | 27 ++++++++++ .../utils/is-erased-typescript-binding.ts | 3 ++ .../is-erased-typescript-namespace.test.ts | 19 +++++++ .../utils/is-erased-typescript-namespace.ts | 27 ++++++++++ 9 files changed, 167 insertions(+) create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md index e86ffeccb3..ec77e28794 100644 --- a/.changeset/quiet-omitted-state-setters.md +++ b/.changeset/quiet-omitted-state-setters.md @@ -9,3 +9,5 @@ Allow immutable value-only useState tuples with omitted setters while preserving Keep runtime loader and provider exposure unsupported when TypeScript-only declarations erase their apparent local bindings. Recognize erased type-only import equals bindings while preserving genuine local runtime values merged with interfaces. + +Recognize empty and type-only namespaces without treating instantiated namespace values as native globals. diff --git a/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx new file mode 100644 index 0000000000..fe3c51df9a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx @@ -0,0 +1,16 @@ +// rule: hook-use-state +// verdict: pass +// weakness: alias-guard +// source: synthetic namespace provider binding +function require() { + return { useState: customHook }; +} +namespace require { + export interface Loader {} +} +require("react").useState = customHook; +import { useState } from "react"; +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx new file mode 100644 index 0000000000..01e3581d9b --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx @@ -0,0 +1,15 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic namespace provider binding +namespace require { + export namespace Shape { + export interface Loader {} + } +} +require("react").useState = customHook; +import { useState } from "react"; +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts index 0dbfd7c184..7cb00fd658 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts @@ -387,6 +387,10 @@ describe("hook-use-state omitted setters", () => { it.each([ `declare const require: any; require("react").useState = customHook;`, `import type require = require("./types"); require("react").useState = customHook;`, + `namespace require {} require("react").useState=customHook;`, + `namespace require {export interface Loader {}} require("react").useState=customHook;`, + `namespace require {type Loader=unknown;} require("react").useState=customHook;`, + `namespace require {export namespace Shape {export interface Loader {}}} require("react").useState=customHook;`, `declare function require(source: string): any; require("react").useState = customHook;`, `interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, `type Reflect = unknown; Reflect.set(provider, "useState", customHook);`, @@ -412,6 +416,9 @@ describe("hook-use-state omitted setters", () => { `const require = () => ({}); require("metadata");`, `const require = () => ({useState: customHook}); interface require {} require("react").useState = customHook;`, `interface require {} const require = () => ({useState: customHook}); require("react").useState = customHook;`, + `namespace require {export const provider={useState:customHook};} require.provider.useState=customHook;`, + `function require(){return {useState:customHook};} namespace require {export interface Loader {}} require("react").useState=customHook;`, + `namespace require {export const provider={useState:customHook};} namespace require {export interface Loader {}} require.provider.useState=customHook;`, `const Object = {defineProperty() {}}; interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, ])("allows unused erased declarations and real local loaders: %s", (metadata) => { const result = runRule( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts index 75a619093f..fbefa50736 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts @@ -6,6 +6,57 @@ import { hasRuntimeValueBinding } from "./has-runtime-value-binding.js"; describe("hasRuntimeValueBinding", () => { it.each([ + { code: "namespace candidate {}", expected: false, nested: false }, + { code: "namespace candidate { interface Marker {} }", expected: false, nested: false }, + { code: "namespace candidate { export interface Marker {} }", expected: false, nested: false }, + { code: "namespace candidate { type Marker = string; }", expected: false, nested: false }, + { + code: "namespace candidate { export type Marker = string; }", + expected: false, + nested: false, + }, + { + code: "namespace candidate { export namespace Nested { export interface Marker {} } }", + expected: false, + nested: false, + }, + { code: "namespace candidate { export const marker = 0; }", expected: true, nested: false }, + { code: "namespace candidate { const marker = 0; }", expected: true, nested: false }, + { code: "namespace candidate { export function marker() {} }", expected: true, nested: false }, + { code: "namespace candidate { export class Marker {} }", expected: true, nested: false }, + { code: "namespace candidate { export enum Marker { Value } }", expected: true, nested: false }, + { + code: "namespace candidate { export namespace Nested { export const marker = 0; } }", + expected: true, + nested: false, + }, + { + code: "namespace candidate { type Marker = string; export const marker = 0; }", + expected: true, + nested: false, + }, + { code: "namespace candidate {;}", expected: true, nested: false }, + { + code: "namespace candidate { declare const marker: unknown; }", + expected: true, + nested: false, + }, + { + code: "namespace candidate { export declare const marker: unknown; }", + expected: true, + nested: false, + }, + { code: "namespace candidate { import Alias = Some.Value; }", expected: false, nested: false }, + { + code: "namespace candidate { export import Alias = Some.Value; }", + expected: true, + nested: false, + }, + { + code: "function candidate() {} namespace candidate { export interface Marker {} }", + expected: true, + nested: false, + }, { code: "interface candidate {}", expected: false, nested: false }, { code: "type candidate = unknown;", expected: false, nested: false }, { code: "declare const candidate: unknown;", expected: false, nested: false }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts index 7b23779894..48c3d151dc 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts @@ -6,6 +6,33 @@ import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; describe("isErasedTypeScriptBinding", () => { it.each([ + { code: "namespace candidate {}", expected: true }, + { code: "namespace candidate { interface Marker {} }", expected: true }, + { code: "namespace candidate { export interface Marker {} }", expected: true }, + { code: "namespace candidate { type Marker = string; }", expected: true }, + { code: "namespace candidate { export type Marker = string; }", expected: true }, + { + code: "namespace candidate { export namespace Nested { export interface Marker {} } }", + expected: true, + }, + { code: "namespace candidate { export const marker = 0; }", expected: false }, + { code: "namespace candidate { const marker = 0; }", expected: false }, + { code: "namespace candidate { export function marker() {} }", expected: false }, + { code: "namespace candidate { export class Marker {} }", expected: false }, + { code: "namespace candidate { export enum Marker { Value } }", expected: false }, + { + code: "namespace candidate { export namespace Nested { export const marker = 0; } }", + expected: false, + }, + { + code: "namespace candidate { type Marker = string; export const marker = 0; }", + expected: false, + }, + { code: "namespace candidate {;}", expected: false }, + { code: "namespace candidate { declare const marker: unknown; }", expected: false }, + { code: "namespace candidate { export declare const marker: unknown; }", expected: false }, + { code: "namespace candidate { import Alias = Some.Value; }", expected: true }, + { code: "namespace candidate { export import Alias = Some.Value; }", expected: false }, { code: "declare const candidate: unknown;", expected: true }, { code: "declare let candidate: unknown;", expected: true }, { code: "declare var candidate: unknown;", expected: true }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts index 19eaed1bdb..2b535d26d5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts @@ -1,4 +1,5 @@ import type { SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptNamespace } from "./is-erased-typescript-namespace.js"; import { getValueImportDeclaration } from "./get-value-import-declaration.js"; export const isErasedTypeScriptBinding = (symbol: SymbolDescriptor): boolean => { @@ -10,6 +11,8 @@ export const isErasedTypeScriptBinding = (symbol: SymbolDescriptor): boolean => symbol.declarationNode.importKind === "type" ) return true; + if (symbol.kind === "ts-module" && isErasedTypeScriptNamespace(symbol.declarationNode)) + return true; let declaration = symbol.declarationNode; while (declaration.parent) { if ("declare" in declaration && declaration.declare === true) return true; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts new file mode 100644 index 0000000000..461e7b930e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isErasedTypeScriptNamespace } from "./is-erased-typescript-namespace.js"; + +describe("dotted TypeScript namespace erasure", () => { + it.each([ + { code: "namespace candidate.Nested {}", expected: true }, + { code: "namespace candidate.Nested { export interface Marker {} }", expected: true }, + { code: "namespace candidate.Nested { export type Marker = string; }", expected: true }, + { code: "namespace candidate.Nested { export const marker = 0; }", expected: false }, + ])("classifies $code", ({ code, expected }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + if (!isNodeOfType(parsed.program, "Program")) throw new Error("Expected namespace program"); + const namespace = parsed.program.body[0]; + expect(namespace && isErasedTypeScriptNamespace(namespace)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts new file mode 100644 index 0000000000..a1cfc39812 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts @@ -0,0 +1,27 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +const erasedNamespaces = new WeakMap(); + +export const isErasedTypeScriptNamespace = (node: EsTreeNode): boolean => { + const cached = erasedNamespaces.get(node); + if (cached !== undefined) return cached; + let isErased = false; + if (isNodeOfType(node, "TSInterfaceDeclaration") || isNodeOfType(node, "TSTypeAliasDeclaration")) + isErased = true; + else if (isNodeOfType(node, "TSImportEqualsDeclaration")) + isErased = + node.importKind === "type" || + !isNodeOfType(node.moduleReference, "TSExternalModuleReference"); + else if (isNodeOfType(node, "ExportNamedDeclaration")) + isErased = isNodeOfType(node.declaration, "TSImportEqualsDeclaration") + ? node.declaration.importKind === "type" + : Boolean(node.declaration && isErasedTypeScriptNamespace(node.declaration)); + else if (isNodeOfType(node, "TSModuleDeclaration") && node.body) + isErased = isNodeOfType(node.body, "TSModuleDeclaration") + ? isErasedTypeScriptNamespace(node.body) + : isNodeOfType(node.body, "TSModuleBlock") && + node.body.body.every(isErasedTypeScriptNamespace); + erasedNamespaces.set(node, isErased); + return isErased; +}; From ebd06afb0f693027ea7c92e67ea0aac836bdfd3a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 16 Oct 2026 21:27:51 -0700 Subject: [PATCH 5/6] fix: preserve runtime provider references through erased bindings --- .../semantic-runtime-reference-resolution.md | 7 + ...te-vercel-snapshot-before-deadline.test.ts | 29 +- ...ate--erased-runtime-provider-reference.tsx | 15 + ...e-state.runtime-provider-reference.test.ts | 56 ++++ .../effect-needs-cleanup.performance.test.ts | 49 ++-- ...hout-functional-update.performance.test.ts | 54 ++-- ...-then-set-or-return-same-reference.test.ts | 90 ++++-- .../scope-analysis.runtime-values.test.ts | 274 ++++++++++++++++++ .../src/plugin/semantic/scope-analysis.ts | 20 +- packages/react-doctor/tests/constants.ts | 1 + .../changed-scope-new-findings.test.ts | 61 ++-- 11 files changed, 553 insertions(+), 103 deletions(-) create mode 100644 .changeset/semantic-runtime-reference-resolution.md create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts diff --git a/.changeset/semantic-runtime-reference-resolution.md b/.changeset/semantic-runtime-reference-resolution.md new file mode 100644 index 0000000000..b26de3ebac --- /dev/null +++ b/.changeset/semantic-runtime-reference-resolution.md @@ -0,0 +1,7 @@ +--- +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Preserve runtime value references and writes when erased TypeScript declarations share their names, so provider exposure cannot bypass diagnostic proofs. diff --git a/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts b/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts index 47ef81c548..a0664e8b6f 100644 --- a/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts +++ b/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts @@ -1,8 +1,14 @@ import { Snapshot } from "@vercel/sandbox"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { deleteVercelSnapshotBeforeDeadline } from "../src/utils/delete-vercel-snapshot-before-deadline.js"; const CONTROL_PLANE_TEST_TIMEOUT_MS = 5; -afterEach(() => vi.restoreAllMocks()); +beforeEach(() => { + vi.useFakeTimers({ toFake: ["performance", "setTimeout", "clearTimeout"] }); +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); describe("deleteVercelSnapshotBeforeDeadline", () => { it("recovers snapshots when the create response was lost", async () => { @@ -32,13 +38,16 @@ describe("deleteVercelSnapshotBeforeDeadline", () => { snapshotId: { value: "snapshot-name" }, delete: { value: () => new Promise(() => undefined) }, }); - await expect( - deleteVercelSnapshotBeforeDeadline({ - credentials: {}, - snapshotName: "builder", - snapshot, - deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS, - }), - ).rejects.toThrow("Timed out deleting Vercel snapshot snapshot-name"); + const deletion = deleteVercelSnapshotBeforeDeadline({ + credentials: {}, + snapshotName: "builder", + snapshot, + deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS, + }); + const deletionAssertion = expect(deletion).rejects.toThrow( + "Timed out deleting Vercel snapshot snapshot-name", + ); + await vi.advanceTimersByTimeAsync(CONTROL_PLANE_TEST_TIMEOUT_MS); + await deletionAssertion; }); }); diff --git a/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx b/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx new file mode 100644 index 0000000000..60809c839e --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx @@ -0,0 +1,15 @@ +// rule: hook-use-state +// weakness: alias-guard +// source: independently-authored-public-synthetic +// verdict: fail +import { useState } from "react"; +import { mutate } from "./external"; +function expose() { + interface useState {} + mutate(useState); +} +expose(); +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts new file mode 100644 index 0000000000..84658386f8 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { hookUseState } from "./hook-use-state.js"; + +const cases = [ + { + name: "useState-unmasked", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-interface", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){interface useState {}mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-type-alias", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){type useState=unknown;mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-real-local-interface", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){const useState=()=>[];interface useState {}mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 0, + }, + { + name: "React-unmasked", + code: 'import React from "react";import {mutate} from "./external";function expose(){mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-interface", + code: 'import React from "react";import {mutate} from "./external";function expose(){interface React {}mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-type-alias", + code: 'import React from "react";import {mutate} from "./external";function expose(){type React=unknown;mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-real-local-interface", + code: 'import React from "react";import {mutate} from "./external";function expose(){const React={useState:()=>[]};interface React {}mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 0, + }, +]; + +describe("hook-use-state runtime provider references", () => { + it.each(cases)("$name", ({ code, expectedCount }) => { + const result = runRule(hookUseState, code, { + filename: "/tmp/rd-runtime-provider-references/view.tsx", + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(expectedCount); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts index 94c1049865..5ab7cc6bfe 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts @@ -1,12 +1,25 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { effectNeedsCleanup } from "./effect-needs-cleanup.js"; const SMALL_HANDLER_COUNT = 500; const LARGE_HANDLER_COUNT = 2500; -const MEASUREMENT_SAMPLE_COUNT = 7; const MAXIMUM_SCALING_MULTIPLIER = 15; +const traversalCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: ((node, visitor) => + originalModule.walkAst(node, (candidate) => { + traversalCounter(); + return visitor(candidate); + })) satisfies typeof originalModule.walkAst, + }; +}); + const buildRetainedHandlersSource = ( handlerCount: number, buildRegistration: (handlerIndex: number) => string, @@ -23,20 +36,16 @@ const buildRetainedHandlersSource = ( return `const Component = () => {\n${handlers}\nreturn <>${buttons};\n};`; }; -const measureDuration = ( +const measureTraversalWork = ( handlerCount: number, buildRegistration: (handlerIndex: number) => string, ): number => { const source = buildRetainedHandlersSource(handlerCount, buildRegistration); - const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => { - const startedAt = process.hrtime.bigint(); - const result = runRule(effectNeedsCleanup, source); - expect(result.parseErrors).toEqual([]); - expect(result.diagnostics).toHaveLength(handlerCount); - return Number(process.hrtime.bigint() - startedAt); - }); - sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration); - return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + traversalCounter.mockClear(); + const result = runRule(effectNeedsCleanup, source); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(handlerCount); + return traversalCounter.mock.calls.length; }; describe("effect-needs-cleanup performance", () => { @@ -52,11 +61,17 @@ describe("effect-needs-cleanup performance", () => { }, ]) { it(`scales near-linearly across retained ${performanceCase.name}`, () => { - measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); - measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); - const smallDuration = measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); - const largeDuration = measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); - expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER); + const smallTraversalCount = measureTraversalWork( + SMALL_HANDLER_COUNT, + performanceCase.buildRegistration, + ); + const largeTraversalCount = measureTraversalWork( + LARGE_HANDLER_COUNT, + performanceCase.buildRegistration, + ); + expect(smallTraversalCount).toBeGreaterThan(SMALL_HANDLER_COUNT); + expect(largeTraversalCount).toBeGreaterThan(smallTraversalCount); + expect(largeTraversalCount).toBeLessThan(smallTraversalCount * MAXIMUM_SCALING_MULTIPLIER); }); } }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts index c22b14689a..c46e431d81 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts @@ -1,48 +1,48 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noBooleanToggleWithoutFunctionalUpdate } from "./no-boolean-toggle-without-functional-update.js"; const SMALL_SETTER_COUNT = 2_000; const LARGE_SETTER_COUNT = 10_000; -const MEASUREMENT_SAMPLE_COUNT = 5; -const MAXIMUM_SCALING_MULTIPLIER = 18; +const INDEPENDENT_FUNCTION_COUNT = 2; +const asyncFunctionScanCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: ((node, visitor) => { + if (node.type === "ArrowFunctionExpression" && "async" in node && node.async) + asyncFunctionScanCounter(); + return originalModule.walkAst(node, visitor); + }) satisfies typeof originalModule.walkAst, + }; +}); const buildAwaitedTogglesSource = (setterCount: number): string => `const C=()=>{const[open,setOpen]=useState(false);const run=async()=>{await load();${"setOpen(!open);".repeat(setterCount)}}}`; -const measureDuration = (source: string, setterCount: number): number => { - const startedAt = process.hrtime.bigint(); +const measureFunctionScans = (source: string, setterCount: number): number => { + asyncFunctionScanCounter.mockClear(); const result = runRule(noBooleanToggleWithoutFunctionalUpdate, source); - const duration = Number(process.hrtime.bigint() - startedAt); expect(result.parseErrors).toEqual([]); expect(result.diagnostics).toHaveLength(setterCount); - return duration; -}; - -const medianDuration = (durations: readonly number[]): number => { - const sortedDurations = [...durations].sort( - (firstDuration, secondDuration) => firstDuration - secondDuration, - ); - return sortedDurations[Math.floor(sortedDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + return asyncFunctionScanCounter.mock.calls.length; }; describe("no-boolean-toggle-without-functional-update performance", () => { it("caches await reachability across many setters", () => { const smallSource = buildAwaitedTogglesSource(SMALL_SETTER_COUNT); const largeSource = buildAwaitedTogglesSource(LARGE_SETTER_COUNT); - measureDuration(smallSource, SMALL_SETTER_COUNT); - measureDuration(largeSource, LARGE_SETTER_COUNT); - const smallDurations: number[] = []; - const largeDurations: number[] = []; - // Interleaving keeps both sizes under the same runner load, and the median - // (unlike the minimum) cannot be won by a single quiet window that only a - // short run is brief enough to fit inside. - for (let sampleIndex = 0; sampleIndex < MEASUREMENT_SAMPLE_COUNT; sampleIndex += 1) { - smallDurations.push(measureDuration(smallSource, SMALL_SETTER_COUNT)); - largeDurations.push(measureDuration(largeSource, LARGE_SETTER_COUNT)); - } - expect(medianDuration(largeDurations)).toBeLessThan( - medianDuration(smallDurations) * MAXIMUM_SCALING_MULTIPLIER, + const smallFunctionScans = measureFunctionScans(smallSource, SMALL_SETTER_COUNT); + const largeFunctionScans = measureFunctionScans(largeSource, LARGE_SETTER_COUNT); + expect(smallFunctionScans).toBeGreaterThan(0); + expect(largeFunctionScans).toBe(smallFunctionScans); + const independentSource = smallSource + ";" + smallSource.replace("const C=", "const Other="); + const independentFunctionScans = measureFunctionScans( + independentSource, + SMALL_SETTER_COUNT * INDEPENDENT_FUNCTION_COUNT, ); + expect(independentFunctionScans).toBe(smallFunctionScans * INDEPENDENT_FUNCTION_COUNT); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts index c328a49d12..93e63c999c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts @@ -1,8 +1,37 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noMutateThenSetOrReturnSameReference } from "./no-mutate-then-set-or-return-same-reference.js"; -const MEASUREMENT_SAMPLE_COUNT = 3; +interface PairingStressFixture { + readonly source: string; + readonly pairingSiteBudget: number; +} + +interface PairingWorkMeasurement { + readonly coexistenceCalls: number; + readonly reachabilityCalls: number; +} + +const coexistenceCounter = vi.hoisted(() => vi.fn()); +const reachabilityCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/nodes-can-co-execute.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + nodesCanCoExecute: coexistenceCounter.mockImplementation(originalModule.nodesCanCoExecute), + }; +}); + +vi.mock("../../utils/can-node-reach-node.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + canNodeReachNode: reachabilityCounter.mockImplementation(originalModule.canNodeReachNode), + }; +}); describe("no-mutate-then-set-or-return-same-reference", () => { it("flags setX(state.add(index)) on a state Set", () => { @@ -561,7 +590,7 @@ describe("no-mutate-then-set-or-return-same-reference", () => { }); it("amortizes mutation and same-reference pairing by control-flow block", () => { - const buildSource = (pairCount: number): string => { + const buildSource = (pairCount: number): PairingStressFixture => { const mutations = "items.push(1);".repeat(pairCount); const conditions = Array.from( { length: pairCount }, @@ -579,21 +608,48 @@ describe("no-mutate-then-set-or-return-same-reference", () => { return `(${buildBalancedResultExpression(startIndex, middleIndex)}||${buildBalancedResultExpression(middleIndex, endIndex)})`; }; const sameReferenceResults = buildBalancedResultExpression(0, resultExpressions.length); - return `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{if(flag){${mutations}}if(!flag){return ${sameReferenceResults}}return [...items]})}`; + const mutationRegions = [`if(flag){${mutations}}`]; + const returnSites = [`if(!flag){return ${sameReferenceResults}}`, "return [...items]"]; + return { + source: `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{${mutationRegions.join("")}${returnSites.join("")}})}`, + pairingSiteBudget: mutationRegions.length * returnSites.length, + }; }; - runRule(noMutateThenSetOrReturnSameReference, buildSource(200)); - const measureFastestDuration = (pairCount: number): number => { - let fastestDuration = Number.POSITIVE_INFINITY; - for (let repetition = 0; repetition < MEASUREMENT_SAMPLE_COUNT; repetition += 1) { - const startedAt = performance.now(); - const result = runRule(noMutateThenSetOrReturnSameReference, buildSource(pairCount)); - fastestDuration = Math.min(fastestDuration, performance.now() - startedAt); - expect(result.diagnostics).toHaveLength(0); - } - return fastestDuration; + const measurePairingWork = ( + source: string, + expectedDiagnosticCount: number, + ): PairingWorkMeasurement => { + coexistenceCounter.mockClear(); + reachabilityCounter.mockClear(); + const result = runRule(noMutateThenSetOrReturnSameReference, source); + expect(result.parseErrors).toHaveLength(0); + expect(result.diagnostics).toHaveLength(expectedDiagnosticCount); + return { + coexistenceCalls: coexistenceCounter.mock.calls.length, + reachabilityCalls: reachabilityCounter.mock.calls.length, + }; }; - const smallDuration = measureFastestDuration(800); - const largeDuration = measureFastestDuration(4_000); - expect(largeDuration).toBeLessThan(smallDuration * 18); + const positive = measurePairingWork( + "const C=()=>{const[,setItems]=useState([]);setItems(items=>{items.push(1);return items})}", + 1, + ); + expect(positive.coexistenceCalls).toBeGreaterThan(0); + expect(positive.reachabilityCalls).toBeGreaterThan(0); + + const smallFixture = buildSource(800); + const small = measurePairingWork(smallFixture.source, 0); + expect(small.coexistenceCalls).toBeGreaterThan(0); + expect(small.coexistenceCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + expect(small.reachabilityCalls).toBeGreaterThan(0); + expect(small.reachabilityCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + + const largeFixture = buildSource(4_000); + const large = measurePairingWork(largeFixture.source, 0); + expect(large.coexistenceCalls).toBeGreaterThan(0); + expect(large.coexistenceCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.reachabilityCalls).toBeGreaterThan(0); + expect(large.reachabilityCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.coexistenceCalls).toBe(small.coexistenceCalls); + expect(large.reachabilityCalls).toBe(small.reachabilityCalls); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts new file mode 100644 index 0000000000..cd27c428fb --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import type { EsTreeNode } from "../utils/es-tree-node.js"; +import { walkAst } from "../utils/walk-ast.js"; +import { analyzeScopes } from "./scope-analysis.js"; +import type { + ReferenceDescriptor, + ReferenceFlag, + SymbolDescriptor, + SymbolKind, +} from "./scope-analysis.js"; + +interface RuntimeResolutionCase { + name: string; + code: string; + expectedKind: SymbolKind | null; + flags: ReferenceFlag[]; +} + +const cases: RuntimeResolutionCase[] = [ + { + name: "destructured parameter beneath interface", + code: "function read({subject}){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "computed key const beneath interface", + code: "const subject='useState'; interface subject{} Object.defineProperty(React,subject,{});", + expectedKind: "const", + flags: ["read"], + }, + { + name: "const before interface", + code: "const subject=1; interface subject{} consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "interface before const", + code: "interface subject{} const subject=1; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "const before type alias", + code: "const subject=1; type subject=unknown; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath inner interface", + code: "const subject=1; function read(){interface subject{} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath inner type alias", + code: "const subject=1; function read(){type subject=unknown; consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath empty namespace", + code: "const subject=1; namespace Outer{namespace subject{} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath type-only namespace", + code: "const subject=1; namespace Outer{namespace subject{export interface Marker{}} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath ambient value", + code: "const subject=1; function read(){declare const subject:unknown; consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "parameter beneath inner interface", + code: "function read(subject){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "parameter beneath inner type alias", + code: "function read(subject){type subject=unknown; consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "parameter writes beneath interface", + code: "function read(subject){interface subject{} subject=2; consume(subject);}", + expectedKind: "parameter", + flags: ["write", "read"], + }, + { + name: "nested parameter writes beneath interface", + code: "function read(subject){function update(){interface subject{} subject+=2;} consume(subject);}", + expectedKind: "parameter", + flags: ["read-write", "read"], + }, + { + name: "nearest real parameter shadows outer const", + code: "const subject=1; function read(subject){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "default import beneath interface", + code: "import subject from 'provider'; interface subject{} consume(subject);", + expectedKind: "import", + flags: ["read"], + }, + { + name: "namespace import beneath nested interface", + code: "import * as subject from 'provider'; function read(){interface subject{} consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "named import beneath nested type alias", + code: "import {subject} from 'provider'; function read(){type subject=unknown; consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "named import beneath type-only namespace", + code: "import {subject} from 'provider'; namespace Outer{namespace subject{export type Marker=unknown;} consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "import write beneath nested interface", + code: "import {subject} from 'provider'; function update(){interface subject{} subject=other;}", + expectedKind: "import", + flags: ["write"], + }, + { + name: "function beneath merged interface", + code: "function subject(){} interface subject{} consume(subject);", + expectedKind: "function", + flags: ["read"], + }, + { + name: "class beneath merged interface", + code: "class subject{} interface subject{} consume(subject);", + expectedKind: "class", + flags: ["read"], + }, + { + name: "runtime namespace beneath interface", + code: "namespace subject{export const present=1;} interface subject{} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "latest real var declaration wins", + code: "var subject; var subject; interface subject{} consume(subject);", + expectedKind: "var", + flags: ["read"], + }, + { + name: "value import-equals stays runtime", + code: "import subject=require('provider'); interface subject{} consume(subject);", + expectedKind: "ts-import-equals", + flags: ["read"], + }, + { + name: "interface-only fallback stays bound", + code: "interface subject{} consume(subject);", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "nearest erased fallback stays bound", + code: "type subject=unknown; function read(){interface subject{} consume(subject);}", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "type-alias-only fallback stays bound", + code: "type subject=unknown; consume(subject);", + expectedKind: "ts-type-alias", + flags: ["read"], + }, + { + name: "type-only import fallback stays bound", + code: "import type {subject} from 'provider'; consume(subject);", + expectedKind: "import", + flags: ["read"], + }, + { + name: "type-only import-equals fallback stays bound", + code: "import type subject=require('provider'); consume(subject);", + expectedKind: "ts-import-equals", + flags: ["read"], + }, + { + name: "ambient-only fallback stays bound", + code: "declare const subject:unknown; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "empty namespace fallback stays bound", + code: "namespace subject{} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "type-only namespace fallback stays bound", + code: "namespace subject{export type Marker=unknown;} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "unrelated inner values do not replace erased fallback", + code: "interface subject{} function unrelated(){const subject=1;} consume(subject);", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "unbound value stays global", + code: "consume(subject);", + expectedKind: null, + flags: ["read"], + }, +]; + +describe("runtime reference resolution across erased declarations", () => { + for (const scenario of cases) { + it(scenario.name, () => { + const parsed = parseFixture(scenario.code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const analysis = analyzeScopes(parsed.program); + const references: ReferenceDescriptor[] = []; + walkAst(parsed.program, (node) => { + if (node.type !== "Identifier" || node.name !== "subject") return; + const reference = analysis.referenceFor(node); + if (reference) references.push(reference); + }); + expect(references.map((reference) => reference.flag)).toEqual(scenario.flags); + const resolved = references[0]?.resolvedSymbol ?? null; + expect(resolved?.kind ?? null).toBe(scenario.expectedKind); + for (const reference of references) { + expect(analysis.symbolFor(reference.identifier)).toBe(resolved); + expect(analysis.isGlobalReference(reference.identifier)).toBe(resolved === null); + if (resolved) expect(resolved.references).toContain(reference); + } + const bindingsByIdentifier = new Map(); + const pending = [analysis.rootScope]; + while (pending.length) { + const scope = pending.pop(); + if (!scope) continue; + pending.push(...scope.children); + for (const binding of scope.symbols) { + const previous = bindingsByIdentifier.get(binding.bindingIdentifier); + if (!previous || previous.id < binding.id) + bindingsByIdentifier.set(binding.bindingIdentifier, binding); + expect(scope.symbolsByName.get(binding.name)?.id).toBe( + scope.symbols.filter((symbol) => symbol.name === binding.name).at(-1)?.id, + ); + } + } + for (const [identifier, binding] of bindingsByIdentifier) + expect(analysis.symbolFor(identifier)?.id).toBe(binding.id); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts index 08f6718178..a7a51a0e5c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts @@ -4,6 +4,7 @@ import { getValuePositionChildKeys } from "../utils/get-value-position-child-key import { isAstNode } from "../utils/is-ast-node.js"; import { isFunctionLike } from "../utils/is-function-like.js"; import { isNodeOfType } from "../utils/is-node-of-type.js"; +import { isErasedTypeScriptBinding } from "../utils/is-erased-typescript-binding.js"; // Scope analyzer — per-file walker building a scope tree, symbol // table, and identifier reference resolution. Mirrors the subset of @@ -856,24 +857,35 @@ const walkValuePositionChildren = (node: EsTreeNode, state: BuilderState): void } }; -// Resolution pass: for each scope's references, walk up the scope -// chain to find the first scope that binds the same name. Set -// `resolvedSymbol` and append to the symbol's `references` list. +// Resolve runtime values first, retaining the nearest erased binding +// when the scope chain contains no runtime value with that name. const resolveReferences = (rootScope: ScopeDescriptor): void => { + const runtimeSymbolsByScope = new Map>(); const visitScope = (scope: ScopeDescriptor): void => { + const runtimeSymbols = new Map(); + for (const symbol of scope.symbols) { + if (!isErasedTypeScriptBinding(symbol)) runtimeSymbols.set(symbol.name, symbol); + } + runtimeSymbolsByScope.set(scope, runtimeSymbols); for (const reference of scope.references) { const name = (reference.identifier as { name?: string }).name; if (typeof name !== "string") continue; let lookup: ScopeDescriptor | null = scope; + let nearestErasedSymbol: SymbolDescriptor | null = null; while (lookup) { - const found = lookup.symbolsByName.get(name); + const found = runtimeSymbolsByScope.get(lookup)?.get(name); if (found) { reference.resolvedSymbol = found; found.references.push(reference); break; } + nearestErasedSymbol ??= lookup.symbolsByName.get(name) ?? null; lookup = lookup.parent; } + if (!reference.resolvedSymbol && nearestErasedSymbol) { + reference.resolvedSymbol = nearestErasedSymbol; + nearestErasedSymbol.references.push(reference); + } } for (const child of scope.children) visitScope(child); }; diff --git a/packages/react-doctor/tests/constants.ts b/packages/react-doctor/tests/constants.ts index d9f948e18d..da33a0b890 100644 --- a/packages/react-doctor/tests/constants.ts +++ b/packages/react-doctor/tests/constants.ts @@ -2,3 +2,4 @@ export const PROMPT_PROBE_OBSERVATION_WINDOW_MS = 750; export const PROMPT_PROBE_STARTUP_TIMEOUT_MS = 10_000; export const PROMPT_PROBE_SLOW_START_MS = 1_500; export const TEMPORARY_DIRECTORY_REMOVE_MAX_RETRIES = 10; +export const CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS = 60_000; diff --git a/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts b/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts index 67af732456..c54c1cf03d 100644 --- a/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts +++ b/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import * as Schema from "effect/Schema"; import { JsonReport } from "@react-doctor/core/schemas"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS } from "../constants.js"; import { commitAll, initGitRepo, writeFile, writeJson } from "./_helpers.js"; const CLI = path.resolve(import.meta.dirname, "../../bin/react-doctor.js"); @@ -248,35 +249,39 @@ describe("changed scope compares findings through the CLI", () => { } }); - it("keeps the HostedCart findings after reformatting and reports only an added derived state", () => { - const sourcePath = path.join(directory, "src/HostedCart.tsx"); - writeFile(sourcePath, HOSTED_CART); - commitAll(directory, "base cart findings"); - const base = scan(directory); - expect(base.report.diagnostics.some((finding) => finding.rule === "exhaustive-deps")).toBe( - true, - ); - expect( - base.report.diagnostics.some( - (finding) => finding.rule === "no-set-state-after-await-in-effect", - ), - ).toBe(true); - writeJson(reportFile, base.report); - for (const appended of ["", NEW_DERIVED_STATE]) { - writeFile(sourcePath, REFORMATTED_HOSTED_CART + appended); - const gitResult = scan(directory, ["--scope", "changed", "--base", "HEAD"]); - const savedResult = scan(directory, ["--scope", "changed", "--baseline", reportFile]); - const fullSavedResult = scan(directory, ["--baseline", reportFile]); - for (const result of [gitResult, savedResult, fullSavedResult]) { - expect(result.report.diagnostics.map((finding) => finding.rule)).toEqual( - appended ? ["no-derived-state"] : [], - ); - expect(result.report.summary.totalDiagnosticCount).toBe(appended ? 1 : 0); - expect(result.status).toBe(appended ? 1 : 0); - if (appended) expect(result.report.diagnostics[0]?.line).toBe(15); + it( + "keeps the HostedCart findings after reformatting and reports only an added derived state", + () => { + const sourcePath = path.join(directory, "src/HostedCart.tsx"); + writeFile(sourcePath, HOSTED_CART); + commitAll(directory, "base cart findings"); + const base = scan(directory); + expect(base.report.diagnostics.some((finding) => finding.rule === "exhaustive-deps")).toBe( + true, + ); + expect( + base.report.diagnostics.some( + (finding) => finding.rule === "no-set-state-after-await-in-effect", + ), + ).toBe(true); + writeJson(reportFile, base.report); + for (const appended of ["", NEW_DERIVED_STATE]) { + writeFile(sourcePath, REFORMATTED_HOSTED_CART + appended); + const gitResult = scan(directory, ["--scope", "changed", "--base", "HEAD"]); + const savedResult = scan(directory, ["--scope", "changed", "--baseline", reportFile]); + const fullSavedResult = scan(directory, ["--baseline", reportFile]); + for (const result of [gitResult, savedResult, fullSavedResult]) { + expect(result.report.diagnostics.map((finding) => finding.rule)).toEqual( + appended ? ["no-derived-state"] : [], + ); + expect(result.report.summary.totalDiagnosticCount).toBe(appended ? 1 : 0); + expect(result.status).toBe(appended ? 1 : 0); + if (appended) expect(result.report.diagnostics[0]?.line).toBe(15); + } } - } - }); + }, + CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS, + ); it.each([ { ignore: { rules: ["react-hooks/exhaustive-deps"] } }, From 1f7953c59095b1a43bbc0b0ca2a5854ac0eda690 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 22 Oct 2026 03:31:05 -0700 Subject: [PATCH 6/6] fix: bound readonly state provider proofs --- .changeset/quiet-omitted-state-setters.md | 2 + ...k-use-state--mutated-provider-accessor.tsx | 13 ++ .../src/plugin/constants/react.ts | 7 + .../src/plugin/constants/thresholds.ts | 4 + ...ok-use-state.numeric-data-provider.test.ts | 5 + .../has-react-default-provider-mutation.ts | 2 + .../is-numeric-data-member-access.test.ts | 35 +++++ .../utils/is-numeric-data-member-access.ts | 39 +++-- ...-unmodified-local-react-state-call.test.ts | 148 ++++++++++++++++++ .../is-unmodified-local-react-state-call.ts | 107 +++++++++---- 10 files changed, 314 insertions(+), 48 deletions(-) create mode 100644 packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md index 0ba9fbe3a6..8e30daf1b3 100644 --- a/.changeset/quiet-omitted-state-setters.md +++ b/.changeset/quiet-omitted-state-setters.md @@ -13,3 +13,5 @@ Recognize erased type-only import equals bindings while preserving genuine local Recognize empty and type-only namespaces without treating instantiated namespace values as native globals. Recognize bounded numeric data indexing with closed callers and native window listeners while preserving runtime loader, provider mutation and callable extraction checks. + +Reuse program-wide provider and numeric helper facts, and reject provider replacements through legacy accessor methods. diff --git a/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx b/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx new file mode 100644 index 0000000000..7fab61f330 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// weakness: alias-guard +// source: independently authored React provider mutation control +// verdict: fail +import React from "react"; + +const replacementState = () => [7]; +React.__defineGetter__("useState", () => replacementState); + +export const MutationPanel = () => { + const [stored] = React.useState(7); + return {stored}; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts index 83273abecb..eb6078bbda 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -322,3 +322,10 @@ export const NON_HOOK_REGISTRATION_APIS: ReadonlyMap export const VALUE_ONLY_STATE_TUPLE_LENGTH = 1; export const CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT = 1; + +export const REACT_PROVIDER_MUTATION_CAPABILITY_NAMES = new Set([ + "__defineGetter__", + "__defineSetter__", + "__proto__", + "prototype", +]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index 242b983707..3b5137ccde 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -85,3 +85,7 @@ export const INERT_REF_ONE_SHOT_TIMER_MAX_DELAY_MS = 300; export const NUMERIC_DATA_SMALL_READ_COUNT = 100; export const NUMERIC_DATA_LARGE_READ_COUNT = 500; export const NUMERIC_DATA_MAXIMUM_VISIT_SCALING_MULTIPLIER = 8; + +export const LOCAL_REACT_STATE_SMALL_CALL_COUNT = 16; +export const LOCAL_REACT_STATE_LARGE_CALL_COUNT = 64; +export const LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER = 6; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts index 7377edd940..e9274812c1 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts @@ -11,6 +11,11 @@ interface NumericDataProviderCase { } const cases: NumericDataProviderCase[] = [ + ...["__defineGetter__", "__defineSetter__"].map((method) => ({ + name: `rejects React provider replacement through ${method}`, + code: `import React from "react";React.${method}("useState",()=>customState);export const Panel=()=>{const [stored]=React.useState(7);return {stored};};`, + expectedCount: 1, + })), { name: "ordinary numeric data", code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;return {storedKey+amount}};', diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts index cc5b41909f..1d8c6c9104 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts @@ -1,3 +1,4 @@ +import { REACT_PROVIDER_MUTATION_CAPABILITY_NAMES } from "../constants/react.js"; import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; @@ -54,6 +55,7 @@ export const hasReactDefaultProviderMutation = ( const referencedProperty = getStaticPropertyName(member); return ( referencedProperty === null || + REACT_PROVIDER_MUTATION_CAPABILITY_NAMES.has(referencedProperty) || (isNamespaceImport && referencedProperty === "default") || (referencedProperty === propertyName && !isNonEscapingReactApiReference(member)) ); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts index 8be89504e1..37bba81fc7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts @@ -6,6 +6,10 @@ import type { EsTreeNode } from "./es-tree-node.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { isNumericDataMemberAccess } from "./is-numeric-data-member-access.js"; import { walkAst } from "./walk-ast.js"; +import { + NUMERIC_DATA_LARGE_READ_COUNT, + NUMERIC_DATA_SMALL_READ_COUNT, +} from "../constants/thresholds.js"; const traversalWork = vi.hoisted(() => ({ programWalkCount: 0 })); @@ -35,6 +39,37 @@ const prepare = (code: string) => { const source = "const getKey=()=>Number(input);const value=items[getKey()]-1;"; describe("numeric data stable facts lifetime", () => { + it("indexes exported bindings once across distinct numeric helper functions", () => { + const programWalkCounts = [NUMERIC_DATA_SMALL_READ_COUNT, NUMERIC_DATA_LARGE_READ_COUNT].map( + (helperCount) => { + const fixture = prepare( + Array.from( + { length: helperCount }, + (_, helperIndex) => + `const getKey${helperIndex}=()=>Number(input);const value${helperIndex}=items[getKey${helperIndex}()]-1;`, + ).join("\n"), + ); + traversalWork.programWalkCount = 0; + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual(fixture.members.map(() => true)); + return traversalWork.programWalkCount; + }, + ); + expect(programWalkCounts[0]).toBeGreaterThan(0); + expect(programWalkCounts[1]).toBe(programWalkCounts[0]); + }); + + it.each([ + "const getKey=()=>Number(input);export {getKey};const value=items[getKey()]-1;", + "const getKey=()=>Number(input);export default getKey;const value=items[getKey()]-1;", + ])("declines exported numeric helpers: %s", (code) => { + const fixture = prepare(code); + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual(fixture.members.map(() => false)); + }); + it("reuses facts only within the exact analysis and Program", () => { const fixture = prepare(source); traversalWork.programWalkCount = 0; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts index 1e2669bd59..20ba47d8cf 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts @@ -18,6 +18,7 @@ import { walkAst } from "./walk-ast.js"; interface NumericDataProgramFacts { hasNumberWrite: boolean | null; + exportedBindings: Set; readonlyBindings: Map; directCalls: Map[] | null>; earliestCalls: Map; @@ -39,6 +40,7 @@ export const isNumericDataMemberAccess = (member: EsTreeNode, scopes: ScopeAnaly if (!facts) { facts = { hasNumberWrite: null, + exportedBindings: new Set(), readonlyBindings: new Map(), directCalls: new Map(), earliestCalls: new Map(), @@ -55,6 +57,21 @@ export const isNumericDataMemberAccess = (member: EsTreeNode, scopes: ScopeAnaly isBindingAssignmentTarget(node) ) facts.hasNumberWrite = true; + if (isNodeOfType(node, "ExportNamedDeclaration") && !node.source) { + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ExportSpecifier")) continue; + const localName = specifier.local.name; + const binding = localName && resolveRuntimeValueBinding(scopes.rootScope, localName); + if (binding) facts.exportedBindings.add(binding); + } + } + if (isNodeOfType(node, "ExportDefaultDeclaration")) { + const exportedValue = stripParenExpression(node.declaration); + const binding = + isNodeOfType(exportedValue, "Identifier") && + resolveRuntimeValueBinding(scopes.rootScope, exportedValue.name); + if (binding) facts.exportedBindings.add(binding); + } }); } if (facts.hasNumberWrite) return false; @@ -100,27 +117,7 @@ export const isNumericDataMemberAccess = (member: EsTreeNode, scopes: ScopeAnaly isNodeOfType(statement?.parent, "ExportDefaultDeclaration") ) return null; - let exported = false; - walkAst(program, (node) => { - if (exported) return false; - if (isNodeOfType(node, "ExportNamedDeclaration") && !node.source) { - for (const specifier of node.specifiers) { - if (!isNodeOfType(specifier, "ExportSpecifier")) continue; - const localName = specifier.local.name; - if (localName && resolveRuntimeValueBinding(scopes.rootScope, localName) === symbol) - exported = true; - } - } - if (isNodeOfType(node, "ExportDefaultDeclaration")) { - const exportedValue = stripParenExpression(node.declaration); - if ( - isNodeOfType(exportedValue, "Identifier") && - resolveRuntimeValueBinding(scopes.rootScope, exportedValue.name) === symbol - ) - exported = true; - } - }); - if (exported) return null; + if (facts.exportedBindings.has(symbol)) return null; const initializer = getDirectConstInitializer(symbol); const calls: EsTreeNodeOfType<"CallExpression">[] = []; let earliestCallPosition = program.range[1]; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts new file mode 100644 index 0000000000..6e160babf6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { + LOCAL_REACT_STATE_LARGE_CALL_COUNT, + LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + LOCAL_REACT_STATE_SMALL_CALL_COUNT, +} from "../constants/thresholds.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isUnmodifiedLocalReactStateCall } from "./is-unmodified-local-react-state-call.js"; +import { parseSourceText } from "./parse-source-file.js"; +import { walkAst } from "./walk-ast.js"; + +interface LocalReactStateWork { + lookups: number; + inspectedProviderReferences: number; +} + +const providerWork = vi.hoisted(() => ({ inspectedReferences: 0 })); + +vi.mock("./is-non-escaping-react-api-reference.js", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + isNonEscapingReactApiReference: (expression: EsTreeNode): boolean => { + providerWork.inspectedReferences += 1; + return original.isNonEscapingReactApiReference(expression); + }, + }; +}); + +const prepare = (sourceText: string) => { + const program = parseSourceText({ filename: "local-state.tsx", sourceText }); + if (!program) throw new Error("Expected parsed source"); + const scopes = analyzeScopes(program); + const calls: EsTreeNode[] = []; + walkAst(program, (node) => { + if ( + isNodeOfType(node, "CallExpression") && + !(isNodeOfType(node.callee, "Identifier") && node.callee.name === "require") + ) + calls.push(node); + }); + return { program, scopes, calls }; +}; + +const measureLookups = (callCount: number, namespace: boolean): LocalReactStateWork => { + const source = namespace ? 'import React from "react";' : 'import {useState} from "react";'; + const callee = namespace ? "React.useState" : "useState"; + const { scopes, calls } = prepare( + source + + Array.from({ length: callCount }, (_, index) => `const stored${index} = ${callee}(0);`).join( + "\n", + ), + ); + providerWork.inspectedReferences = 0; + let lookups = 0; + const observedScopes = { + ...scopes, + symbolFor: (identifier: Parameters[0]) => { + lookups += 1; + return scopes.symbolFor(identifier); + }, + }; + expect(calls).toHaveLength(callCount); + for (const call of calls) + expect(isUnmodifiedLocalReactStateCall(call, observedScopes)).toBe(true); + return { lookups, inspectedProviderReferences: providerWork.inspectedReferences }; +}; + +describe("local React state provider facts", () => { + it.each([false, true])("bounds repeated provider work for namespace=$0", (namespace) => { + const small = measureLookups(LOCAL_REACT_STATE_SMALL_CALL_COUNT, namespace); + const large = measureLookups(LOCAL_REACT_STATE_LARGE_CALL_COUNT, namespace); + expect(small.lookups).toBeGreaterThan(0); + expect(small.inspectedProviderReferences).toBeGreaterThan(0); + expect(large.lookups).toBeLessThan( + small.lookups * LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + ); + expect(large.inspectedProviderReferences).toBeLessThan( + small.inspectedProviderReferences * LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + ); + }); + + it("keeps the allowed require node separate from imported call proofs", () => { + const { scopes, calls } = prepare( + 'import {useState} from "react";const React=require("react");const first=useState(0);const second=React.useState(1);', + ); + expect(calls).toHaveLength(2); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + expect(isUnmodifiedLocalReactStateCall(calls[1], scopes)).toBe(true); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + }); + + it("keeps initializer ordering outside the memoized provider proof", () => { + const { scopes, calls } = prepare( + 'React.useState(0);const React=require("react");React.useState(1);', + ); + expect(calls).toHaveLength(2); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + expect(isUnmodifiedLocalReactStateCall(calls[1], scopes)).toBe(true); + }); + + it("does not reuse a valid provider proof for an unsupported call shape", () => { + const { scopes, calls } = prepare( + 'import React from "react";React.useState(0);React.namespace.useState(0);React.useRef(0);React["useState"](0);', + ); + expect(calls.map((call) => isUnmodifiedLocalReactStateCall(call, scopes))).toEqual([ + true, + false, + false, + true, + ]); + }); + + it("keeps numeric-data permission separate in both query orders", () => { + for (const firstMode of [false, true]) { + const { scopes, calls } = prepare( + 'import {useState} from "react";export const Panel=input=>{const [stored]=useState(7);const values=[3,7];const getKey=()=>Number(input);const total=values[getKey()];return {stored+total}};', + ); + const stateCall = calls.find( + (call) => + isNodeOfType(call, "CallExpression") && + isNodeOfType(call.callee, "Identifier") && + call.callee.name === "useState", + ); + if (!stateCall) throw new Error("Expected state call"); + for (const mode of [firstMode, !firstMode, firstMode]) { + expect( + isUnmodifiedLocalReactStateCall(stateCall, scopes, { allowNumericDataAccess: mode }), + ).toBe(mode); + } + } + }); + + it("does not carry a safe proof into a fresh hazardous program", () => { + for (const mutation of ["", "React.__defineGetter__('useState',()=>customState);", ""]) { + const { scopes, calls } = prepare( + 'import React from "react";' + mutation + "React.useState(0);", + ); + const stateCall = calls[calls.length - 1]; + expect(isUnmodifiedLocalReactStateCall(stateCall, scopes)).toBe(!mutation); + const freshScopes = analyzeScopes(scopes.rootScope.node); + expect(isUnmodifiedLocalReactStateCall(stateCall, freshScopes)).toBe(!mutation); + } + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts index 063d6bb204..a9c54bf382 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts @@ -1,5 +1,5 @@ import { CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT } from "../constants/react.js"; -import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; import type { EsTreeNode } from "./es-tree-node.js"; import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; @@ -22,6 +22,7 @@ import { isNumericDataMemberAccess } from "./is-numeric-data-member-access.js"; import { isMemberMutationTarget } from "./is-member-mutation-target.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isReactApiCall } from "./is-react-api-call.js"; import { isProvenUnmodifiedReactApiCall } from "./is-proven-unmodified-react-api-call.js"; import { stripParenExpression } from "./strip-paren-expression.js"; import { walkAst } from "./walk-ast.js"; @@ -44,6 +45,15 @@ interface LocalReactStateCallOptions { readonly allowNumericDataAccess?: boolean; } +interface LocalReactStateProgramFacts { + providerCalls: Map; + hasProviderMutation: boolean | null; + providerSymbolIds: Set | null; + providerHazards: Map>; +} + +const programFacts = new WeakMap>(); + export const isUnmodifiedLocalReactStateCall = ( call: EsTreeNode, scopes: ScopeAnalysis, @@ -54,6 +64,22 @@ export const isUnmodifiedLocalReactStateCall = ( const receiver = getRootIdentifier(callee); const symbol = receiver ? scopes.symbolFor(receiver) : null; if (!receiver || !symbol) return false; + let scopeFacts = programFacts.get(scopes); + if (!scopeFacts) { + scopeFacts = new WeakMap(); + programFacts.set(scopes, scopeFacts); + } + const program = scopes.rootScope.node; + let facts = scopeFacts.get(program); + if (!facts) { + facts = { + providerCalls: new Map(), + hasProviderMutation: null, + providerSymbolIds: null, + providerHazards: new Map(), + }; + scopeFacts.set(program, facts); + } const importDeclaration = getImportDeclarationForSymbol(symbol); let allowedRequire: EsTreeNode | null = null; if (importDeclaration) { @@ -61,10 +87,16 @@ export const isUnmodifiedLocalReactStateCall = ( importDeclaration.importKind === "type" || (isNodeOfType(symbol.declarationNode, "ImportSpecifier") && symbol.declarationNode.importKind === "type") || - !isProvenUnmodifiedReactApiCall(call, "useState", scopes) + !isReactApiCall(call, "useState", scopes) ) { return false; } + let validProvider = facts.providerCalls.get(symbol); + if (validProvider === undefined) { + validProvider = isProvenUnmodifiedReactApiCall(call, "useState", scopes); + facts.providerCalls.set(symbol, validProvider); + } + if (!validProvider) return false; } else { const declaration = symbol.declarationNode; const initializer = symbol.initializer ? stripParenExpression(symbol.initializer) : null; @@ -79,38 +111,58 @@ export const isUnmodifiedLocalReactStateCall = ( initializer.arguments.length !== CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT || initializer.optional || initializer.range[1] > call.range[0] || - getGlobalRequireModuleSource(initializer, scopes) !== "react" || - symbol.references.some((reference) => { - if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { - return true; - } - const referenceRoot = findTransparentExpressionRoot(reference.identifier); - const member = referenceRoot.parent; - if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { - return true; - } - const propertyName = getStaticPropertyName(member); - return ( - propertyName === null || - propertyName === "default" || - (propertyName === "useState" && !isNonEscapingReactApiReference(member)) - ); - }) || - hasPossibleStaticPropertyMutationOrEscape(receiver, "useState", scopes) + getGlobalRequireModuleSource(initializer, scopes) !== "react" ) { return false; } + let validProvider = facts.providerCalls.get(symbol); + if (validProvider === undefined) { + validProvider = !( + symbol.references.some((reference) => { + if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { + return true; + } + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return true; + } + const propertyName = getStaticPropertyName(member); + return ( + propertyName === null || + propertyName === "default" || + (propertyName === "useState" && !isNonEscapingReactApiReference(member)) + ); + }) || hasPossibleStaticPropertyMutationOrEscape(receiver, "useState", scopes) + ); + facts.providerCalls.set(symbol, validProvider); + } + if (!validProvider) return false; allowedRequire = initializer; } - if (hasReactDefaultProviderMutation("useState", scopes)) return false; - const providerSymbolIds = new Set(); - for (const provider of scopes.rootScope.symbols) { - const declaration = getImportDeclarationForSymbol(provider); - if (declaration?.source.value !== "react") continue; - for (const equivalent of getEquivalentSymbols(provider.bindingIdentifier, scopes)) { - providerSymbolIds.add(equivalent.id); + if (facts.hasProviderMutation === null) { + facts.hasProviderMutation = hasReactDefaultProviderMutation("useState", scopes); + } + if (facts.hasProviderMutation) return false; + if (!facts.providerSymbolIds) { + facts.providerSymbolIds = new Set(); + for (const provider of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(provider); + if (declaration?.source.value !== "react") continue; + for (const equivalent of getEquivalentSymbols(provider.bindingIdentifier, scopes)) { + facts.providerSymbolIds.add(equivalent.id); + } } } + let providerHazards = facts.providerHazards.get(allowedRequire); + if (!providerHazards) { + providerHazards = new Map(); + facts.providerHazards.set(allowedRequire, providerHazards); + } + const allowNumericDataAccess = Boolean(options.allowNumericDataAccess); + const cachedHazard = providerHazards.get(allowNumericDataAccess); + if (cachedHazard !== undefined) return !cachedHazard; + const providerSymbolIds = new Set(facts.providerSymbolIds); if (allowedRequire) providerSymbolIds.add(symbol.id); let hasProviderHazard = false; walkAst(scopes.rootScope.node, (child) => { @@ -248,5 +300,6 @@ export const isUnmodifiedLocalReactStateCall = ( hasProviderHazard = true; } }); + providerHazards.set(allowNumericDataAccess, hasProviderHazard); return !hasProviderHazard; };