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/native-submit-helper-event-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"react-doctor": patch
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
---

Avoid reporting render-time navigation when a local helper is reached only through a returned native form submit handler.
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,24 @@
// rule: react-router-no-navigate-in-render
// weakness: control-flow
// source: independent synthetic native submit callback
// verdict: fail
import { useNavigate } from "react-router-dom";

interface SubmitEvent {
preventDefault(): void;
}

export const SaveScreen = () => {
const navigate = useNavigate();
const SAVE_RECORD = () => navigate("/saved");
const handleSubmit = (event: SubmitEvent) => {
event.preventDefault();
SAVE_RECORD();
};
SAVE_RECORD();
return (
<form onSubmit={handleSubmit}>
<button type="submit">Save</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// rule: react-router-no-navigate-in-render
// weakness: alias-guard
// source: independent synthetic native submit runtime binding control
// verdict: pass
import "react";
import { useNavigate } from "react-router-dom";
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = async () => {
await Promise.resolve();
navigate("/next");
};
const onSubmit = (event: { preventDefault: () => void }) => {
interface CREATE_SESSION {}
event.preventDefault();
CREATE_SESSION();
};
return (
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// rule: react-router-no-navigate-in-render
// weakness: control-flow
// source: independent synthetic native submit callback
// verdict: pass
import { useNavigate } from "react-router-dom";

interface SubmitEvent {
preventDefault(): void;
}

export const SaveScreen = () => {
const navigate = useNavigate();
const SAVE_RECORD = () => navigate("/saved");
const handleSubmit = (event: SubmitEvent) => {
event.preventDefault();
SAVE_RECORD();
};
return (
<form onSubmit={handleSubmit}>
<button type="submit">Save</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// rule: react-router-no-navigate-in-render
// weakness: alias-guard
// source: independent synthetic native submit runtime binding control
// verdict: pass
import "react";
import { useNavigate } from "react-router-dom";
const module = { require: (_source: string) => ({ useNavigate: 0 }) };
interface module {}
const loadedRouter = module.require("react-router-dom");
loadedRouter.useNavigate = 1;
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = () => {
navigate("/next");
};
const onSubmit = (event: { preventDefault: () => void }) => {
event.preventDefault();
CREATE_SESSION();
};
return (
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// rule: react-router-no-navigate-in-render
// weakness: alias-guard
// source: independent synthetic native submit runtime binding control
// verdict: pass
import { Fragment } from "react";
import { useNavigate } from "react-router-dom";
interface Fragment {}
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = () => {
navigate("/next");
};
const onSubmit = (event: { preventDefault: () => void }) => {
event.preventDefault();
CREATE_SESSION();
};
return (
<Fragment>
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
</Fragment>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// rule: react-router-no-navigate-in-render
// weakness: alias-guard
// source: independent synthetic native submit runtime binding control
// verdict: pass
import "react";
import { useNavigate } from "react-router-dom";
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = () => {
navigate("/next");
};
const onSubmit = (event: { preventDefault: () => void }) => {
event.preventDefault();
CREATE_SESSION();
};
interface onSubmit {}
return (
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// rule: react-router-no-navigate-in-render
// weakness: control-flow
// source: independent synthetic closed submit provider control
// verdict: pass
import "react";
import { useNavigate } from "react-router-dom";
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = () => {
navigate("/next");
};
interface CREATE_SESSION {}
const onSubmit = (event: { preventDefault: () => void }) => {
event.preventDefault();
CREATE_SESSION();
};
return (
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// rule: react-router-no-navigate-in-render
// weakness: control-flow
// source: independent synthetic closed submit provider control
// verdict: fail
import "react";
import { useNavigate } from "react-router-dom";
const loadedRouter = module.require("react-router-dom");
loadedRouter.useNavigate = replacement;
export const Screen = () => {
const navigate = useNavigate();
const CREATE_SESSION = () => {
navigate("/next");
};
const onSubmit = (event: { preventDefault: () => void }) => {
event.preventDefault();
CREATE_SESSION();
};
return (
<form onSubmit={onSubmit}>
<button type="submit">Go</button>
</form>
);
};
1 change: 1 addition & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export const STATE_SNIPPET_POOL = [
// Handlers — async submits with loading flags, keyboard commit paths,
// numeric input parsing, window.open, clipboard, toggles.
export const HANDLER_SNIPPET_POOL = [
`const FuzzNativeSubmitHelper = () => { const navigate = useNavigate(); const SAVE_RECORD = () => navigate("/saved"); const handleSubmit = (event) => { event.preventDefault(); SAVE_RECORD(); }; return <form onSubmit={handleSubmit}><button type="submit">Save</button></form>; }; const fuzzSubmitForm = <FuzzNativeSubmitHelper />;`,
`const latestRequest = { current: 0 }; const latestReview = { current: 0 }; const handleFreshRequest = async () => { const requestId = latestRequest.current; const reviewVersion = latestReview.current; const response = await fetch(url); if (requestId !== latestRequest.current || reviewVersion !== latestReview.current) return; setState(await response.json()); };`,
`const handleSyncRequest = () => { const request = new XMLHttpRequest(); request.open("GET", String(url), false); request.send(); };`,
`const handleSubmit = async () => { setLoading(true); try { await fetch(url, { method: "POST", body: JSON.stringify(values) }); setState(true); } catch (submitError) { setError(submitError); } finally { setLoading(false); } };`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vite-plus/test";
import { attachParentReferences } from "../../../test-utils/attach-parent-references.js";
import { parseFixture } from "../../../test-utils/parse-fixture.js";
import { analyzeControlFlow } from "../../semantic/control-flow-graph.js";
import { analyzeScopes } from "../../semantic/scope-analysis.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { walkAst } from "../../utils/walk-ast.js";
import { isClosedNativeSubmitNavigation } from "./utils/is-closed-native-submit-navigation.js";

describe("closed native submit source context", () => {
for (const firstPlain of [true, false]) {
it(`retains text boundaries with ${firstPlain ? "plain source" : "missing source"} first`, () => {
const source =
"import{useNavigate}from'react-router-dom';export const Screen=()=>{const navigate=useNavigate();const SAVE_RECORD=()=>navigate('/next');const onSubmit=(event:{preventDefault():void})=>{event.preventDefault();SAVE_RECORD()};return <form onSubmit={onSubmit}/>};";
const parsed = parseFixture(source);
expect(parsed.errors).toEqual([]);
attachParentReferences(parsed.program);
const scopes = analyzeScopes(parsed.program);
const cfg = analyzeControlFlow(parsed.program);
const calls: EsTreeNodeOfType<"CallExpression">[] = [];
const declarations: EsTreeNodeOfType<"VariableDeclarator">[] = [];
walkAst(parsed.program, (node) => {
if (
isNodeOfType(node, "CallExpression") &&
isNodeOfType(node.callee, "Identifier") &&
node.callee.name === "navigate"
)
calls.push(node);
if (
isNodeOfType(node, "VariableDeclarator") &&
isNodeOfType(node.id, "Identifier") &&
node.id.name === "navigate"
)
declarations.push(node);
});
const call = calls[0];
const declaration = declarations[0];
if (!call || !declaration) throw new Error("Expected navigation call and declaration");
const contexts: ReadonlyArray<RuleContext> = [
{
scopes,
cfg,
sourceCode: { ast: parsed.program, getText: () => source },
report: () => {},
},
{
scopes,
cfg,
sourceCode: { ast: parsed.program, getText: () => "/** @jsx custom */\n" + source },
report: () => {},
},
{ scopes, cfg, report: () => {} },
];
const plain = contexts[0];
const pragma = contexts[1];
const missing = contexts[2];
if (!plain || !pragma || !missing) throw new Error("Expected source contexts");
const sequence = firstPlain
? [plain, pragma, missing, plain, pragma, missing]
: [missing, pragma, plain, missing, plain, pragma];
expect(
sequence.map((context) => isClosedNativeSubmitNavigation(call, declaration, context)),
).toEqual(
firstPlain
? [true, false, false, true, false, false]
: [false, false, true, false, true, false],
);
let currentSource = source;
let receiverCalls = 0;
const sourceCode = {
ast: parsed.program,
getText() {
expect(this).toBe(sourceCode);
receiverCalls += 1;
return currentSource;
},
};
const mutableContext = { scopes, cfg, sourceCode, report: () => {} };
const expected = firstPlain ? [true, false, true, false] : [false, true, false, true];
expect(
expected.map((isPlain) => {
currentSource = isPlain ? source : "/** @jsx custom */\n" + source;
return isClosedNativeSubmitNavigation(call, declaration, mutableContext);
}),
).toEqual(expected);
expect(receiverCalls).toBe(expected.length);
});
}
});
Loading
Loading