Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/quiet-react-bench-lifecycle-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
---

Preserve resource-lifecycle resets, controlled state fallbacks, and prop-originated synchronization without hiding genuine child-owned state handoffs.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// weakness: cleanup-provenance
// source: issue #1558

import { useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";

export const Watchdog = ({ AppState, done }) => {
useEffect(() => {
Expand Down Expand Up @@ -37,3 +37,21 @@ export const Tabs = ({ onPress, tabs }) => {
}, [onPress, tabs]);
return null;
};

export const PermissionCard = ({ interactive, requestId, resolved, timeoutMs }) => {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);

useEffect(() => {
if (resolved || !interactive) return;
timerRef.current = setInterval(() => tick(requestId, timeoutMs), 1000);
return () => stopTimer();
}, [interactive, requestId, resolved, stopTimer, timeoutMs]);

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// verdict: pass
// rule: exhaustive-deps
// weakness: unreachable-fresh-fallback
// source: React Bench RangeSelect

import { useMemo, useState } from "react";

interface RangeSelectProps {
defaultValue?: string[];
placeholder: string;
value?: string[];
}

export const RangeSelect = ({ defaultValue, placeholder, value }: RangeSelectProps) => {
const [internalSelectedOptions] = useState(defaultValue ?? []);
const isControlled = value !== undefined;
const selectedOptions = (isControlled ? value : internalSelectedOptions) ?? [];
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => selectedOptions.join(", ") || placeholder, [selectedOptions, placeholder]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// verdict: pass
// rule: exhaustive-deps
// weakness: controlled-state-global-generic
// source: Bugbot PR #1579

import { useCallback, useState } from "react";

interface ArrayRangeSelectProps {
onChange: (value: Array<string>) => void;
value?: Array<string>;
}

export const ArrayRangeSelect = ({ onChange, value }: ArrayRangeSelectProps) => {
const [internalSelectedOptions] = useState<Array<string>>([]);
const isControlled = value !== undefined;
const selectedOptions = (isControlled ? value : internalSelectedOptions) ?? [];
return useCallback(() => onChange(selectedOptions), [onChange, selectedOptions]);
};

interface ReadonlyArrayRangeSelectProps {
onChange: (value: ReadonlyArray<string>) => void;
value?: ReadonlyArray<string>;
}

export const ReadonlyArrayRangeSelect = ({ onChange, value }: ReadonlyArrayRangeSelectProps) => {
const [internalSelectedOptions] = useState<ReadonlyArray<string>>([]);
const isControlled = value !== undefined;
const selectedOptions = (isControlled ? value : internalSelectedOptions) ?? [];
return useCallback(() => onChange(selectedOptions), [onChange, selectedOptions]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// verdict: pass
// rule: no-reset-all-state-on-prop-change
// weakness: resource-lifecycle
// source: React Bench OpenFlipbook and Glific

import { useCallback, useEffect, useRef, useState } from "react";

export const GeometryOverlay = ({ nodeId, status }) => {
const [phase, setPhase] = useState("idle");
const previousRef = useRef({ nodeId, status });
const controllerRef = useRef<AbortController | null>(null);

useEffect(() => {
const previous = previousRef.current;
const didResourceChange = previous.nodeId !== nodeId || previous.status !== status;
if (didResourceChange) {
controllerRef.current?.abort();
controllerRef.current = null;
setPhase("idle");
}
previousRef.current = { nodeId, status };
}, [nodeId, status]);

return phase;
};

export const EvaluationList = ({ searchQuery }) => {
const [pendingDownloads, setPendingDownloads] = useState<Record<string, boolean>>({});
const requestsRef = useRef(new Map<string, AbortController>());
const abortAllRequests = () => {
requestsRef.current.forEach((controller) => controller.abort());
requestsRef.current.clear();
};

useEffect(() => {
if (!requestsRef.current.size) return;
abortAllRequests();
setPendingDownloads({});
}, [searchQuery]);

return Object.keys(pendingDownloads).length;
};

export const VersionedResource = ({ resourceId }) => {
const [phase, setPhase] = useState("idle");
const generationRef = useRef(0);

useEffect(() => {
generationRef.current += 1;
setPhase("idle");
}, [resourceId]);

return phase;
};

export const MemoizedCleanupResource = ({ resourceId }) => {
const [phase, setPhase] = useState("idle");
const controllerRef = useRef<AbortController | null>(null);
const abortRequest = useCallback(() => controllerRef.current?.abort(), []);

useEffect(() => {
setPhase("idle");
return () => abortRequest();
}, [resourceId, abortRequest]);

return phase;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// verdict: pass
// rule: no-pass-data-to-parent, no-pass-live-state-to-parent, no-prop-callback-in-effect
// weakness: prop-provenance
// source: React Bench MultiSelectField

import { useEffect, useRef, useState } from "react";

const useDeepCompareMemoize = <Value,>(value: Value): Value => {
const valueRef = useRef(value);
if (JSON.stringify(valueRef.current) !== JSON.stringify(value)) valueRef.current = value;
return valueRef.current;
};

export const MultiSelectField = ({ values, onPendingChange }) => {
const [preValues, setPreValues] = useState([]);
const memoizedValues = useDeepCompareMemoize(values);
const onPendingChangeRef = useRef(onPendingChange);

useEffect(() => {
onPendingChangeRef.current = onPendingChange;
}, [onPendingChange]);

useEffect(() => {
setPreValues(memoizedValues);
onPendingChangeRef.current?.(memoizedValues);
}, [memoizedValues]);

return preValues.length;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// verdict: pass
// rule: no-pass-data-to-parent, no-prop-callback-in-effect
// weakness: prop-provenance
// source: React Bench MultiSelectField

import { useEffect, useEffectEvent, useMemo, useState } from "react";

export const SerializedValues = ({ values, onPendingChange }) => {
const valuesKey = JSON.stringify(values);

useEffect(() => {
const parsedValues = JSON.parse(valuesKey);
onPendingChange?.(parsedValues);
}, [valuesKey, onPendingChange]);

return null;
};

export const MemoizedValues = ({ values, onPendingChange }) => {
const resolvedValues = useMemo(() => values ?? [], [values]);
const reportPendingChange = useEffectEvent((nextValues) => {
onPendingChange?.(nextValues);
});

useEffect(() => {
reportPendingChange(resolvedValues);
}, [resolvedValues]);

return null;
};

export const OpenLifecycle = ({ values, onPendingChange, onSearch }) => {
const [isOpen, setIsOpen] = useState(false);
const [pendingValues, setPendingValues] = useState(values);
const [searchValue, setSearchValue] = useState("stale");

useEffect(() => {
if (!isOpen) return;
setPendingValues(values);
onPendingChange?.(values);
setSearchValue("");
onSearch?.("");
}, [isOpen, values, onPendingChange, onSearch]);

return (
<button onClick={() => setIsOpen(true)}>
Open {pendingValues.length + searchValue.length}
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// verdict: fail
// rule: exhaustive-deps
// weakness: imported-global-type-shadow
// source: Bugbot PR #1579

import { useCallback, useState } from "react";
import type { Selection as Array } from "./selection";

interface RangeSelectProps {
onChange: (value: Array<string>) => void;
value?: Array<string>;
}

export const RangeSelect = ({ onChange, value }: RangeSelectProps) => {
const [internalSelectedOptions] = useState<string[]>([]);
const isControlled = value !== undefined;
const selectedOptions = (isControlled ? value : internalSelectedOptions) ?? [];
return useCallback(() => onChange(selectedOptions), [onChange, selectedOptions]);
};
46 changes: 27 additions & 19 deletions packages/fuzz/tests/fuzz-harness-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ const VERDICT_DIRECTIVE_PATTERN = /^\/\/ verdict: (pass|fail)$/m;

describe("fuzz harness oracles", () => {
it("reads corpus directives from CRLF files", () => {
const code = "// rule: example-rule\r\n// verdict: fail\r\n";
const code = "// rule: example-rule, second-rule\r\n// verdict: fail\r\n";

expect(RULE_DIRECTIVE_PATTERN.exec(code)?.[1]).toBe("example-rule");
expect(
RULE_DIRECTIVE_PATTERN.exec(code)?.[1]
?.split(",")
.map((ruleId) => ruleId.trim()),
).toEqual(["example-rule", "second-rule"]);
expect(VERDICT_DIRECTIVE_PATTERN.exec(code)?.[1]).toBe("fail");
});

Expand Down Expand Up @@ -85,29 +89,33 @@ describe("fuzz harness oracles", () => {
let declaredVerdictCount = 0;

for (const entry of corpus) {
const ruleId = RULE_DIRECTIVE_PATTERN.exec(entry.code)?.[1];
const ruleIds = RULE_DIRECTIVE_PATTERN.exec(entry.code)?.[1]
?.split(",")
.map((ruleId) => ruleId.trim());
const verdict = VERDICT_DIRECTIVE_PATTERN.exec(entry.code)?.[1];
if (!verdict) continue;
declaredVerdictCount += 1;
if (!ruleId) {
if (!ruleIds?.length) {
verdictFailures.push(`${entry.relativePath}: missing rule`);
continue;
}
const rule = rulesById.get(ruleId);
if (!rule) {
verdictFailures.push(`${entry.relativePath}: unknown rule ${ruleId}`);
continue;
}
const result = runRule(rule, entry.code, {
filename: entry.relativePath,
settings: livenessFixturesById.get(ruleId)?.settings,
forceJsx: true,
});
const didFire = result.diagnostics.length > 0;
if ((verdict === "fail") !== didFire) {
verdictFailures.push(
`${entry.relativePath}: expected ${verdict}, received ${result.diagnostics.length} diagnostics`,
);
for (const ruleId of ruleIds) {
const rule = rulesById.get(ruleId);
if (!rule) {
verdictFailures.push(`${entry.relativePath}: unknown rule ${ruleId}`);
continue;
}
const result = runRule(rule, entry.code, {
filename: entry.relativePath,
settings: livenessFixturesById.get(ruleId)?.settings,
forceJsx: true,
});
const didFire = result.diagnostics.length > 0;
if ((verdict === "fail") !== didFire) {
verdictFailures.push(
`${entry.relativePath} (${ruleId}): expected ${verdict}, received ${result.diagnostics.length} diagnostics`,
);
}
}
}

Expand Down
Loading
Loading