Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/imported-call-static-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Avoid recommending module-scope hoisting for literals that evaluate imported functions, including readonly const aliases. This conservatively retains per-render evaluation without classifying imported functions as impure.
7 changes: 7 additions & 0 deletions .changeset/semantic-runtime-reference-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Preserve runtime value references and writes when erased TypeScript declarations share their names, so provider exposure cannot bypass diagnostic proofs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// rule: prefer-module-scope-static-value
// verdict: fail
// weakness: cross-file
// source: independent synthetic imported-call boundary regression
import readSnapshot from "./state-reader";
const selectedReader = readSnapshot;
({ selectedReader } = { selectedReader: () => "replacement" });
export const useSettings = () => {
const options = { label: selectedReader() };
return options;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// rule: prefer-module-scope-static-value
// verdict: pass
// weakness: cross-file
// source: independent synthetic imported evaluation control
import readSnapshot from "synthetic-snapshot-provider";

const firstReader = readSnapshot;
const selectedReader = firstReader;

export const useSnapshotOptions = () => {
const options = { label: selectedReader() };
return options;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// rule: prefer-module-scope-static-value
// verdict: pass
// weakness: cross-file
// source: independent synthetic imported-call boundary regression
import { useState } from "react";
import readSnapshot from "./state-reader";
interface readSnapshot {}
export const useSettings = () => {
const [enabled] = useState(true);
const options = { label: readSnapshot() };
return { options, enabled };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// rule: prefer-module-scope-static-value
// verdict: fail
// weakness: cross-file
// source: independent synthetic static-allocation positive control

export const StaticOptions = () => {
const options = { label: "fixed" };
return <output>{options.label}</output>;
};
1 change: 1 addition & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export const LIBRARY_SNIPPET_POOL = [

// Module scope — SSR hazards, guard aliases, contexts, caches, styled.
export const MODULE_SCOPE_SNIPPET_POOL = [
`import { readSnapshot as fuzzReadImportedSnapshot } from "synthetic-snapshot-provider"; const fuzzSnapshotReader = fuzzReadImportedSnapshot; export const useFuzzImportedOptions = () => { const options = { label: fuzzSnapshotReader() }; return options; }; export const FuzzStaticOptionsControl = () => { const options = { label: "fixed" }; return <output>{options.label}</output>; };`,
`import { create as createFuzzZustandStore } from "zustand"; export const useFuzzZustandStore = createFuzzZustandStore(() => ({ items: [] })); export const fuzzZustandSelection = useFuzzZustandStore((state) => ({ items: state.items }));`,
`import { create as createFuzzZustandTarget } from "zustand"; const useFuzzZustandTarget = createFuzzZustandTarget(() => ({ value: 0, other: 0 })); export const FuzzZustandWholeStoreTarget = () => { const { value: fuzzZustandValue } = useFuzzZustandTarget(); return <output>{fuzzZustandValue}</output>; };`,
`import { create as createFuzzZustandGetStore } from "zustand"; const useFuzzZustandGetStore = createFuzzZustandGetStore((_set, get) => ({ count: get().count }));`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { preferModuleScopeStaticValue } from "./prefer-module-scope-static-value.js";

const cases = [
{
name: "baseline-imported-evaluation",
code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n',
expected: 0,
},
{
name: "merged-imported-reader-interface",
code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface readSnapshot {}\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n',
expected: 0,
},
{
name: "merged-readonly-reader-alias-interface",
code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface selectedReader {}\nconst selectedReader = readSnapshot;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectedReader() };\n return { options, enabled };\n};\n',
expected: 0,
},
{
name: "merged-intermediate-reader-alias-interface",
code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface firstReader {}\nconst firstReader = readSnapshot;\nconst selectedReader = firstReader;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectedReader() };\n return { options, enabled };\n};\n',
expected: 0,
},
{
name: "genuine-local-reader-with-interface",
code: 'import { useState } from "react";\nconst readSnapshot = () => "fixed";\ninterface readSnapshot {}\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n',
expected: 1,
},
{
name: "ordinary-local-reader-control",
code: 'import { useState } from "react";\nconst readSnapshot = () => "fixed";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n',
expected: 1,
},
{
name: "type-only-reader-import-control",
code: 'import { useState } from "react";\nimport type readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n',
expected: 1,
},
{
name: "static-object-activation",
code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: "fixed" };\n return { options, enabled };\n};\n',
expected: 1,
},
{
name: "plain-const-alias-write",
code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\nselectedReader = () => "replacement";\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n',
expected: 1,
},
{
name: "destructured-const-alias-write",
code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n({ selectedReader } = { selectedReader: () => "replacement" });\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n',
expected: 1,
},
{
name: "readonly-const-alias",
code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n',
expected: 0,
},
{
name: "destructuring-rhs-readonly-alias",
code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nconst holder: {reader?: unknown} = {};\n({reader: holder.reader} = {reader: selectedReader});\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n',
expected: 0,
},
{
name: "destructuring-computed-key-readonly-alias",
code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nlet ignored: unknown;\n({[selectedReader.name]: ignored} = {});\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n',
expected: 0,
},
];

describe("imported call runtime binding and mutation boundaries", () => {
for (const testCase of cases) {
it(testCase.name, () => {
const result = runRule(preferModuleScopeStaticValue, testCase.code, {
filename: "src/imported-call-review.tsx",
includeLocations: true,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(testCase.expected);
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vite-plus/test";
import type { SymbolDescriptor } from "../../semantic/scope-analysis.js";
import { analyzeScopes } from "../../semantic/scope-analysis.js";
import { attachParentReferences } from "../../../test-utils/attach-parent-references.js";
import { parseFixture } from "../../../test-utils/parse-fixture.js";
import { runRule } from "../../../test-utils/run-rule.js";
import {
IMPORTED_CALL_AST_READS_PER_NODE,
IMPORTED_CALL_PROOF_COUNTS,
IMPORTED_CALL_REFERENCE_PASSES,
} from "../../../test-utils/imported-call-proof/constants.js";
import { containsImportedValueCall } from "../../utils/contains-imported-value-call.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import { walkAst } from "../../utils/walk-ast.js";
import { preferModuleScopeStaticValue } from "./prefer-module-scope-static-value.js";

describe("imported call cold proof work", () => {
for (const directImport of [true, false]) {
for (const count of IMPORTED_CALL_PROOF_COUNTS) {
it(`${count} initializers sharing ${directImport ? "an import" : "a readonly alias chain"}`, () => {
const aliases = directImport
? ""
: Array.from(
{ length: count },
(_, index) =>
`const ${index === count - 1 ? "selectedReader" : `reader${index}`}=${index === 0 ? "readSnapshot" : `reader${index - 1}`};`,
).join("");
const callee = directImport ? "readSnapshot" : "selectedReader";
const declarations = Array.from(
{ length: count },
(_, index) => `const options${index}={label:${callee}()};`,
).join("");
const returned = Array.from({ length: count }, (_, index) => `options${index}`).join(",");
const code = `import{useState}from'react';import readSnapshot from'./state-reader';${aliases}export const useSettings=()=>{const[enabled]=useState(true);${declarations}return{${returned},enabled}};`;
const rejectedCode = directImport
? code.replace(
"import readSnapshot from'./state-reader';",
"const readSnapshot=()=> 'fixed';",
)
: code.replace("const selectedReader=", "let selectedReader=");
const parsed = parseFixture(code);
attachParentReferences(parsed.program);
const scopes = analyzeScopes(parsed.program);
const nodes: EsTreeNode[] = [];
const initializers: EsTreeNode[] = [];
const symbols = new Set<SymbolDescriptor>();
walkAst(parsed.program, (node) => {
nodes.push(node);
if (
isNodeOfType(node, "VariableDeclarator") &&
isNodeOfType(node.id, "Identifier") &&
node.id.name.startsWith("options") &&
node.init
)
initializers.push(node.init);
if (isNodeOfType(node, "Identifier")) {
const symbol = scopes.symbolFor(node);
if (symbol) symbols.add(symbol);
}
});
let referenceVisits = 0;
let referenceCount = 0;
let astTypeReads = 0;
for (const symbol of symbols) {
const references = symbol.references;
referenceCount += references.length;
Object.defineProperty(symbol, "references", {
configurable: true,
value: new Proxy(references, {
get(target, property, receiver) {
if (typeof property === "string" && /^\d+$/.test(property)) referenceVisits++;
return Reflect.get(target, property, receiver);
},
}),
});
}
for (const node of nodes) {
const nodeType = node.type;
Object.defineProperty(node, "type", {
configurable: true,
enumerable: true,
get: () => {
astTypeReads++;
return nodeType;
},
});
}
expect(parsed.errors).toEqual([]);
expect(
initializers.map((initializer) => containsImportedValueCall(initializer, scopes)),
).toEqual(Array.from({ length: count }, () => true));
expect(referenceVisits).toBeLessThanOrEqual(
referenceCount * IMPORTED_CALL_REFERENCE_PASSES,
);
expect(astTypeReads).toBeLessThanOrEqual(nodes.length * IMPORTED_CALL_AST_READS_PER_NODE);
const options = { filename: "src/imported-call-work.tsx", includeLocations: true };
const actual = runRule(preferModuleScopeStaticValue, code, options);
const rejected = runRule(preferModuleScopeStaticValue, rejectedCode, options);
expect(actual.parseErrors).toEqual([]);
expect(actual.diagnostics).toHaveLength(0);
expect(rejected.parseErrors).toEqual([]);
expect(rejected.diagnostics).toHaveLength(count);
});
}
}
});
Loading
Loading