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
17 changes: 17 additions & 0 deletions .changeset/quiet-omitted-state-setters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"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.

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.

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.
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.
29 changes: 19 additions & 10 deletions packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -32,13 +38,16 @@ describe("deleteVercelSnapshotBeforeDeadline", () => {
snapshotId: { value: "snapshot-name" },
delete: { value: () => new Promise<never>(() => 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;
});
});
Original file line number Diff line number Diff line change
@@ -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 };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// rule: hook-use-state
// weakness: dynamic-computed
// source: independently authored numeric data and listener example
// verdict: pass
import { useEffect, useState } from "react";

export const NumericPanel = ({ rawIndex }) => {
const [storedKey] = useState(7);
const getIndex = (value) => Number(value);
const updateValues = (position) => {
const values = [3, 7];
values[position] = 8;
const previousValue = values[position];
values.reverse();
return values.indexOf(previousValue);
};
const amount = updateValues(getIndex(rawIndex));
useEffect(() => {
const listener = () => {
void amount;
};
window.addEventListener("sample", listener);
return () => window.removeEventListener("sample", listener);
}, [amount]);
return <span>{storedKey + amount}</span>;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{seededTotal}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{value}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{seededTotal}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <span>{stored}</span>;
};
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{value}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{seededTotal}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <div>{value}</div>;
};
1 change: 1 addition & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,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 = <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
10 changes: 10 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,13 @@ export const NAVIGATION_RECEIVER_NAMES = new Set([
export const NON_HOOK_REGISTRATION_APIS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
["react_ujs", new Set(["useContext", "useContexts"])],
]);

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",
]);
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,11 @@ export const MAX_MEMO_COMPARATOR_SYMBOLIC_ATOM_COUNT = 8;
export const LARGE_TEXT_OPTIMIZATION_THRESHOLD_CHARS = 1000;
export const STATIC_STRING_VALUE_LIMIT = 32;
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;
Loading
Loading