Skip to content
Draft
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/next-query-state-snapshot.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
---

Allow an omitted state setter for a supported native query snapshot populated from an unchanged Next page input.
1 change: 1 addition & 0 deletions packages/core/tests/cross-file-rule-ids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ describe("CROSS_FILE_RULE_IDS", () => {
"effect-listener-cleanup-reference-mismatch",
"effect-needs-cleanup",
"exhaustive-deps",
"hook-use-state",
"iframe-missing-sandbox",
"ink-ctrl-c-handler-requires-exit-option",
"ink-newline-inside-text",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { QueryPanel } from "./query-panel";

export default async function Page({ searchParams }) {
const query = await searchParams;
return <QueryPanel query={query} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// rule: hook-use-state
// weakness: cross-file
// verdict: pass
// file-path: corpus/regressions/hook-use-state--next-query-snapshot/app/search/query-panel.tsx
"use client";

import { useState } from "react";

export const QueryPanel = ({ query }) => {
const [params] = useState(() => {
const result = new URLSearchParams();
for (const key in query) {
result.set(key, query[key] ?? "");
}
return result;
});
return <span>{params.toString()}</span>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const config = {};
export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"private": true,
"dependencies": {
"next": "16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3"
}
}
69 changes: 69 additions & 0 deletions packages/fuzz/tests/next-query-state-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as path from "node:path";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vite-plus/test";
import { hookUseState } from "../../oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.js";
import { runRule } from "../../oxlint-plugin-react-doctor/src/test-utils/run-rule.js";
import { fuzzRuleWithStats } from "../src/fuzz-rule.js";

const directory = fileURLToPath(
new URL("../corpus/regressions/hook-use-state--next-query-snapshot/", import.meta.url),
);
const filename = fileURLToPath(
new URL(
"../corpus/regressions/hook-use-state--next-query-snapshot/app/search/query-panel.tsx",
import.meta.url,
),
);
const source = readFileSync(filename, "utf8");
const settings = { "react-doctor": { rootDirectory: directory } };

describe("Next query state snapshot corpus", () => {
it("resolves a relative physical project filename", () => {
const result = runRule(hookUseState, source, {
filename: path.relative(process.cwd(), filename),
settings,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("checks the complete project and a nonnative constructor inverse", () => {
const positive = runRule(hookUseState, source, { filename, settings });
expect(positive.parseErrors).toEqual([]);
expect(positive.diagnostics).toEqual([]);
const inverse = runRule(
hookUseState,
source.replace("new URLSearchParams()", 'new URLSearchParams("term=value")'),
{ filename, settings },
);
expect(inverse.parseErrors).toEqual([]);
expect(inverse.diagnostics).toHaveLength(1);
});

it("retains advice when source lacks an authoritative physical project", () => {
const result = runRule(hookUseState, source, { filename: "fixture.tsx" });
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("replays the declared positive project corpus", () => {
const result = fuzzRuleWithStats("hook-use-state", hookUseState, {
iterations: 0,
settings,
corpus: [
{
code: source,
relativePath:
"regressions/hook-use-state--next-query-snapshot/app/search/query-panel.tsx",
sourcePath: filename,
verdict: "pass",
ruleIds: ["hook-use-state"],
},
],
});
expect(result.findings).toEqual([]);
expect(result.stats.executedProgramCount).toBe(1);
expect(result.stats.skippedParseErrorCount).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const CROSS_FILE_RULE_IDS: ReadonlySet<string> = new Set([
"effect-listener-cleanup-reference-mismatch",
"anchor-target-exists",
"exhaustive-deps",
"hook-use-state",
"iframe-missing-sandbox",
"no-barrel-import",
"nextjs-async-dynamic-api-not-awaited",
Expand Down
95 changes: 95 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/constants/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,98 @@ export const GOOGLE_ANALYTICS_SCRIPT_PATTERN = /google-analytics\.com|googletagm
export const OG_IMAGE_FILE_PATTERN = new RegExp(
`/(opengraph-image|twitter-image)\\d*\\.${NEXTJS_SOURCE_FILE_EXTENSION_GROUP}$`,
);

export const NEXT_QUERY_SUPPORTED_MAJOR_VERSION = 16;
export const NEXT_QUERY_SINGLE_VALUE_COUNT = 1;
export const NEXT_QUERY_SET_ARGUMENT_COUNT = 2;
export const NEXT_QUERY_CONFIG_FILE_NAMES = [
"next.config.ts",
"next.config.js",
"next.config.mjs",
"next.config.mts",
];
export const NEXT_QUERY_PROVIDER_MEMBERS: ReadonlySet<string> = new Set([
"constructor",
"prototype",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"URLSearchParams",
"Array",
"Function",
"eval",
"require",
"module",
"defaultView",
"contentWindow",
"ownerDocument",
"view",
"valueOf",
]);
export const NEXT_QUERY_OUTSIDE_REFUSED_MEMBERS: ReadonlySet<string> = new Set([
"constructor",
"prototype",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"getPrototypeOf",
"setPrototypeOf",
"getOwnPropertyDescriptor",
"getOwnPropertyDescriptors",
"defineProperty",
"defineProperties",
"URLSearchParams",
"Array",
"Object",
"Reflect",
"Function",
"eval",
"require",
"module",
]);
export const NEXT_QUERY_ARRAY_METHODS: ReadonlySet<string> = new Set(["from", "isArray", "of"]);
export const NEXT_QUERY_OBJECT_METHODS: ReadonlySet<string> = new Set([
"keys",
"values",
"entries",
"is",
"hasOwn",
]);
export const NEXT_QUERY_REACT_PROVIDER_SOURCES: ReadonlySet<string> = new Set([
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
]);
export const NEXT_QUERY_UNSUPPORTED_MODULE_SOURCES: ReadonlySet<string> = new Set([
"module",
"node:module",
"vm",
"node:vm",
"next/script",
]);
export const NEXT_QUERY_ACTIVE_HTML_ELEMENTS: ReadonlySet<string> = new Set([
"script",
"iframe",
"embed",
"object",
]);
export const NEXT_QUERY_ACTIVE_HTML_ATTRIBUTES: ReadonlySet<string> = new Set([
"srcDoc",
"dangerouslySetInnerHTML",
]);
export const NEXT_QUERY_WINDOW_CARRIER_MEMBERS: ReadonlySet<string> = new Set([
"defaultView",
"contentWindow",
"view",
"window",
"self",
"top",
"parent",
"frames",
"opener",
]);
export const NEXT_QUERY_MAX_WINDOW_ALIAS_COUNT = 32;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { findNativeQueryStateRead } from "./utils/find-native-query-state-read.js";
import { hasSupportedRadixDraftEventCall } from "./rules/state-and-effects/utils/has-supported-radix-draft-event-call.js";
import { findEnclosingFunction } from "./utils/find-enclosing-function.js";
import { hasCurrentContextRecordEnvironment } from "./rules/state-and-effects/utils/has-current-context-record-environment.js";
Expand All @@ -24,7 +25,7 @@ import { INK_RULE_IDS } from "./constants/ink.js";
import { REACT_ROUTER_RULE_IDS } from "./constants/react-router.js";
import { classifyPackagePlatform } from "./utils/classify-package-platform.js";
import { collectImportedJsxComponentDependencies } from "./utils/collect-imported-jsx-component-dependencies.js";
import { collectCrossFileProbes } from "./utils/cross-file-probe-recorder.js";
import { captureCrossFileProbes } from "./utils/cross-file-probe-recorder.js";
import type { CrossFileProbeTrace } from "./utils/cross-file-probe-recorder.js";
import type { EsTreeNode } from "./utils/es-tree-node.js";
import { attachParentReferences } from "./utils/attach-parent-references.js";
Expand Down Expand Up @@ -101,7 +102,9 @@ interface CrossFileDependencyCollectorInput {
readonly getProgram: () => EsTreeNode;
}

type CrossFileDependencyCollector = (input: CrossFileDependencyCollectorInput) => void;
interface CrossFileDependencyCollector {
(input: CrossFileDependencyCollectorInput): void | false;
}

interface ImportEntryName {
readonly source: string;
Expand Down Expand Up @@ -195,6 +198,18 @@ const collectNextjsSearchParamsDependencies: CrossFileDependencyCollector = ({
}
};

const collectNextQueryStateDependencies: CrossFileDependencyCollector = ({ getProgram }) => {
const program = getProgram();
attachParentReferences(program);
const scopes = analyzeScopes(program);
let hasCandidate = false;
walkAst(program, (node) => {
if (hasCandidate) return false;
if (findNativeQueryStateRead(node, scopes)) hasCandidate = true;
});
if (hasCandidate) return false;
};

const collectEffectValueHelperDependencies: CrossFileDependencyCollector = ({
absoluteFilePath,
staticImports,
Expand Down Expand Up @@ -652,6 +667,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap<string, CrossFileDepe
["effect-listener-cleanup-reference-mismatch", collectEffectValueHelperDependencies],
["exhaustive-deps", collectForwardedHookDependencies],
["rules-of-hooks", collectImportedHookDependencies],
["hook-use-state", collectNextQueryStateDependencies],
["no-ref-current-in-render", collectTypedReduxStoreInitializationDependencies],
["no-promise-then-side-effect-in-effect-without-catch", collectOidcUserManagerDependencies],
["no-barrel-import", collectNoBarrelImportDependencies],
Expand Down Expand Up @@ -722,7 +738,7 @@ export const UNBOUNDED_CROSS_FILE_RULE_IDS: ReadonlySet<string> = new Set([
*
* Returns `null` (caller must treat the file as unfingerprintable and always
* re-lint it) when the file has a fatal parse error — an execution the
* collectors cannot mirror — or when a requested rule has no collector.
* collectors cannot mirror — when a collector refuses fingerprinting, or when a requested rule has no collector.
*/
export const collectCrossFileDependencyProbes = (input: {
absoluteFilePath: string;
Expand Down Expand Up @@ -758,7 +774,11 @@ export const collectCrossFileDependencyProbes = (input: {
staticImports,
getProgram,
};
return collectCrossFileProbes(() => {
for (const collector of collectors) collector(collectorInput);
const captured = captureCrossFileProbes(() => {
for (const collector of collectors) {
if (collector(collectorInput) === false) return false;
}
return true;
});
return captured.value ? captured.trace : null;
};
Loading
Loading