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
9 changes: 9 additions & 0 deletions .changeset/primitive-array-updater-slice-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"react-doctor": patch
"eslint-plugin-react-doctor": patch
"oxlint-plugin-react-doctor": patch
---

Avoid reporting a finite local primitive-array slice copy and outer write as an external state-updater side effect.

Preserve real runtime bindings across inert TypeScript declarations, reject visible runtime loader provider replacements and destructuring writes, and cache immutable primitive slice proofs.
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,21 @@
// rule: no-side-effect-in-state-updater-function
// verdict: fail
// weakness: copy-tracking
// source: independent synthetic regression
import { useState } from "react";
interface ObservedSliceEditorProps {
position: number;
label: string;
onCopy: (copy: string[]) => void;
}
export const ObservedSliceEditor = ({ position, label, onCopy }: ObservedSliceEditorProps) => {
const [entries, setEntries] = useState<string[]>([]);
const update = () =>
setEntries((previous) => {
const copy = previous.slice(0, position);
onCopy(copy);
copy[position] = label;
return copy;
});
return <button onClick={update}>{entries[0]}</button>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// rule: no-side-effect-in-state-updater-function
// verdict: pass
// weakness: copy-tracking
// source: independent synthetic regression
import { useState } from "react";
interface SliceEditorProps {
position: number;
label: string;
}
export const SliceEditor = ({ position, label }: SliceEditorProps) => {
const [entries, setEntries] = useState<string[]>([]);
const update = (nextPosition: number, nextLabel: string) =>
setEntries((previous) => {
const copy = previous.slice(0, nextPosition);
copy[nextPosition] = nextLabel;
return copy;
});
return <button onClick={() => update(position, label)}>{entries[0]}</button>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// rule: no-side-effect-in-state-updater-function
// verdict: pass
// file-path: src/components/primitive-slice.tsx
// weakness: alias-guard
// source: synthetic primitive slice review
import { useState } from "react";
export const Example = (position: number, label: string) => {
interface label {}
const [entries, setEntries] = useState<string[]>([]);
setEntries((previous) => {
const copy = previous.slice(0, position);
copy[position] = label;
return copy;
});
return <output>{entries[0]}</output>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// rule: no-side-effect-in-state-updater-function
// verdict: pass
// file-path: src/components/primitive-slice.tsx
// weakness: alias-guard
// source: synthetic primitive slice review
const require = () => ({ useState: 0 });
interface require {}
require("react").useState = 1;
import { useState } from "react";
export const Example = (position: number, label: string) => {
const [entries, setEntries] = useState<string[]>([]);
setEntries((previous) => {
const copy = previous.slice(0, position);
copy[position] = label;
return copy;
});
return <output>{entries[0]}</output>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// rule: no-side-effect-in-state-updater-function
// verdict: pass
// file-path: src/components/primitive-slice.tsx
// weakness: alias-guard
// source: synthetic primitive slice review
import { useState } from "react";
export const Example = (position: number, label: string) => {
const [entries, setEntries] = useState<string[]>([]);
interface entries {}
setEntries((previous) => {
const copy = previous.slice(0, position);
copy[position] = label;
return copy;
});
return <output>{entries[0]}</output>;
};
2 changes: 2 additions & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ 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 [fuzzSliceEntries,setFuzzSliceEntries]=useState<string[]>([]);setFuzzSliceEntries(previous=>{const copy=previous.slice(0,1);copy[0]="updated";return copy;});const fuzzSliceEntry=fuzzSliceEntries[0];`,
`const [fuzzExposedEntries,setFuzzExposedEntries]=useState<string[]>([]);setFuzzExposedEntries(previous=>{const copy=previous.slice(0,1);onSave(copy);copy[0]="updated";return copy;});const fuzzExposedEntry=fuzzExposedEntries[0];`,
`const fuzzValtioState = fuzzValtioProxy({ count: 0 }); const fuzzValtioSnapshot = useFuzzValtioSnapshot(fuzzValtioState); const handleFuzzValtioRead = () => console.log(fuzzValtioSnapshot.count); const fuzzValtioButton = <button onClick={handleFuzzValtioRead}>Read</button>;`,
`const FuzzArrayConsumer = memo(() => null); const FuzzArrayHost = () => <FuzzArrayConsumer payload={[value]} />;`,
`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 <button onClick={toggleLoading}>{isLoading ? "Loading" : "Go"}</button>; } function FuzzMemoEarlyReturnHost({ loading }) { const content = useMemo(() => <Heavy />, []); if (loading) return null; return <div>{content}</div>; }`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vite-plus/test";
import { analyzeControlFlow } from "../../semantic/control-flow-graph.js";
import { analyzeScopes } from "../../semantic/scope-analysis.js";
import type { SymbolDescriptor } 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 type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import { walkAst } from "../../utils/walk-ast.js";
import { noSideEffectInStateUpdaterFunction } from "./no-side-effect-in-state-updater-function.js";
import { isPrimitiveArraySliceCopyWrite } from "./utils/is-primitive-array-slice-copy-write.js";

for (const count of [20, 100]) {
describe(`primitive slice proof work with ${count} consumers`, () => {
for (const shape of ["distinct-state-pairs", "same-state-observations", "imported-producer"]) {
it(`indexes ${shape} without repeated reference scans`, () => {
const states = Array.from(
{ length: shape === "distinct-state-pairs" ? count : 1 },
(_, index) => {
const initializer =
shape === "distinct-state-pairs"
? 'previous.slice(0,1);copy[0]="updated"'
: "previous.slice(0,position);copy[position]=label";
const producerWrites =
shape === "imported-producer"
? `setEntries${index}(loadEntries());`.repeat(count)
: "";
return `const[entries${index},setEntries${index}]=useState<string[]>([]);${producerWrites}setEntries${index}(previous=>{const copy=${initializer};return copy;});`;
},
).join("");
const observations =
shape === "same-state-observations" ? "void entries0[position];".repeat(count) : "";
const producerImport =
shape === "imported-producer" ? 'import{loadEntries}from"./entry-data";' : "";
const code = `${producerImport}import{useState}from"react";export const Example=(position:number,label:string)=>{${states}${observations}return <output>{entries0[0]}</output>;};`;
const parsed = parseFixture(code);
if (!isNodeOfType(parsed.program, "Program")) throw new Error("Missing synthetic program");
attachParentReferences(parsed.program);
const scopes = analyzeScopes(parsed.program);
const cfg = analyzeControlFlow(parsed.program);
const writes: EsTreeNodeOfType<"AssignmentExpression">[] = [];
const trackedSymbols = new Set<SymbolDescriptor>();
let trackedName = "loadEntries";
if (shape === "distinct-state-pairs") trackedName = "useState";
else if (shape === "same-state-observations") trackedName = "position";
walkAst(parsed.program, (node) => {
if (
isNodeOfType(node, "AssignmentExpression") &&
isNodeOfType(node.left, "MemberExpression") &&
isNodeOfType(node.left.object, "Identifier") &&
node.left.object.name === "copy"
)
writes.push(node);
if (isNodeOfType(node, "Identifier") && node.name === trackedName) {
const symbol = scopes.symbolFor(node);
if (symbol) trackedSymbols.add(symbol);
}
});
let referenceVisits = 0;
for (const symbol of trackedSymbols) {
Object.defineProperty(symbol, "references", {
value: new Proxy(symbol.references, {
get: (target, property, receiver) => {
if (typeof property === "string" && /^\d+$/.test(property)) referenceVisits += 1;
return Reflect.get(target, property, receiver);
},
}),
});
}
let programBodyReads = 0;
const body = parsed.program.body;
Object.defineProperty(parsed.program, "body", {
enumerable: true,
get: () => {
programBodyReads += 1;
return body;
},
});
expect(writes).toHaveLength(shape === "distinct-state-pairs" ? count : 1);
expect(trackedSymbols.size).toBe(1);
for (const write of writes) {
const updater = write.parent?.parent?.parent;
const setterCall = updater?.parent;
if (
!isNodeOfType(updater, "ArrowFunctionExpression") ||
!isNodeOfType(setterCall, "CallExpression")
)
throw new Error("Missing synthetic slice updater");
expect(
isPrimitiveArraySliceCopyWrite(write, updater, setterCall, {
scopes,
cfg,
report: () => undefined,
}),
).toBe(true);
}
expect(parsed.errors).toEqual([]);
expect(programBodyReads).toBeLessThanOrEqual(2);
expect(referenceVisits).toBeLessThanOrEqual(count * 2 + 2);
expect(runRule(noSideEffectInStateUpdaterFunction, code).diagnostics).toHaveLength(0);
const replacement = `Array.prototype.slice=replacement;${code}`;
const rejected = runRule(noSideEffectInStateUpdaterFunction, replacement);
expect(rejected.parseErrors).toEqual([]);
expect(rejected.diagnostics).toHaveLength(shape === "distinct-state-pairs" ? count : 1);
});
}
});
}
Loading
Loading