facebook / react · Issue No. 37224
taken into the input.hasErrors, which is still false even though the error that set it to true is being displayed.Without the compiler the button is correctly disabled.
useFormErrors runs two independent validation checks. Each one appends a message and sets a shared hasErrors flag:
let hasErrors = false;
const nameErrors = [];
if (existingNames.includes(name)) {
hasErrors = true;
nameErrors.push('That name is already taken');
}
const settingsErrors = [];
if (settings.chartView === 'histogram' && !settings.xVariableId) {
hasErrors = true;
settingsErrors.push('Pick an X variable');
}
return { hasErrors, nameErrors, settingsErrors };
hasErrors is a plain local, reassigned only in straight-line if blocks that provably run during render. Nothing escapes, nothing is mutated outside the hook, the function is pure and deterministic. As far as I can tell this does not violate any rule of React — and correspondingly no compiler diagnostic and no eslint-plugin-react-hooks rule fires (verified with v7.1.1, react-hooks/immutability enabled at severity 2: zero errors on this file).
The compiler puts the two checks in two separate reactive scopes, and each scope caches and restores hasErrors independently. Verbatim Playground output for the hook:
function useFormErrors(name, existingNames, settings) {
const $ = _c(13);
const [showAllErrors, setShowAllErrors] = useState(false);
let hasErrors = false;
let nameErrors;
if ($[0] !== existingNames || $[1] !== name) {
nameErrors = [];
if (existingNames.includes(name)) {
hasErrors = true;
nameErrors.push("That name is already taken");
}
$[0] = existingNames;
$[1] = name;
$[2] = nameErrors;
$[3] = hasErrors;
} else {
nameErrors = $[2];
hasErrors = $[3];
}
let settingsErrors;
if ($[4] !== settings.chartView || $[5] !== settings.xVariableId) {
settingsErrors = [];
if (settings.chartView === "histogram" && !settings.xVariableId) {
hasErrors = true;
settingsErrors.push("Pick an X variable");
}
$[4] = settings.chartView;
$[5] = settings.xVariableId;
$[6] = settingsErrors;
$[7] = hasErrors;
} else {
settingsErrors = $[6];
hasErrors = $[7];
}
let t0;
if (
$[8] !== hasErrors ||
$[9] !== nameErrors ||
$[10] !== settingsErrors ||
$[11] !== showAllErrors
) {
t0 = {
hasErrors,
nameErrors,
settingsErrors,
showAllErrors,
setShowAllErrors,
};
$[8] = hasErrors;
$[9] = nameErrors;
$[10] = settingsErrors;
$[11] = showAllErrors;
$[12] = t0;
} else {
t0 = $[12];
}
return t0;
}
hasErrors is cached twice — $[3] at the end of the first scope, $[7] at the end of the second — and each else branch restores its own copy unconditionally. Scope 2's hasErrors = $[7] therefore discards whatever scope 1 just computed.
Everything downstream handles it correctly, which isolates the defect: the returned object's scope guards on $[8] !== hasErrors, and in App the button compiles to if ($[7] !== hasErrors) { t4 = <button disabled={hasErrors}>Save</button>; }. The wrong value originates at the cross-scope restore.
When name changes but settings does not:
hasErrors = true$[7], cached from a render where it was falsehasErrors is treated as an output of each scope independently, but it is really a value threaded through both. The later scope's cached copy wins over the earlier scope's fresh computation.
Running the compiled hook directly across two renders:
name="" hasErrors=false nameErrors=[]
name="taken" hasErrors=false nameErrors=["That name is already taken"]
^ should be true
The message and the flag disagree, which is the user-visible symptom.
useEffectEvent stale capture). This repro has no loop and no effect event, so I believe it is distinct — happy to be pointed at a dupe.InferReactiveScopeVariables treating a reassigned primitive as a scope dependency rather than an output), but that fix targets loop back-edge phis. This repro has no loop — just two sequential scopes — and the symptom is the opposite: #34971 caused cache misses (a perf problem), this causes a stale cache hit (a correctness problem).1.0.0 and on the newest published experimental build 0.0.0-experimental-a1856f3-20260507, so it does not appear to be fixed on main as of that build.hasErrors flag — is very common in form code, and the failure is silent, so it may be affecting apps that have not noticed.Every time
19.2.5
babel-plugin-react-compiler@1.0.0 (also reproduced on 0.0.0-experimental-a1856f3-20260507)
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.