Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 packages/oxlint-plugin-react-doctor/src/plugin/constants/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,20 @@ export const MUTATING_ROUTE_SEGMENTS = new Set([
"cancel",
"deactivate",
]);

export const ERROR_BOUNDARY_FILE_PATTERN = /\/(error|global-error)\.(tsx?|jsx?)$/;

export const GLOBAL_ERROR_FILE_PATTERN = /\/global-error\.(tsx?|jsx?)$/;

export const ROUTE_HANDLER_HTTP_METHODS = new Set([
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
]);

export const GOOGLE_ANALYTICS_SCRIPT_PATTERN =
/google-analytics\.com|googletagmanager\.com\/gtag|www\.googletagmanager\.com/;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 GOOGLE_ANALYTICS_SCRIPT_PATTERN regex matches Google Tag Manager scripts, producing misleading diagnostics

The third alternative in the regex (www\.googletagmanager\.com) matches all scripts from www.googletagmanager.com, including Google Tag Manager scripts (e.g. https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX). These are not Google Analytics scripts, yet the rule fires with the message "Manual Google Analytics script blocks rendering" and recommends importing GoogleAnalytics from @next/third-parties/google. For a GTM script, the correct component would be GoogleTagManager. Additionally, this matching is inconsistent — GTM scripts from googletagmanager.com (without www.) are not caught, as confirmed by testing the regex.

Regex test results
  • www.googletagmanager.com/gtm.js?id=GTM-XXX → matches (false positive, it's GTM not GA)
  • googletagmanager.com/gtm.js?id=GTM-XXX → does NOT match (inconsistent)
  • www.googletagmanager.com/gtag/js?id=G-XXX → matches (correct, GA4)
  • www.google-analytics.com/analytics.js → matches (correct, old GA)
Suggested change
export const GOOGLE_ANALYTICS_SCRIPT_PATTERN =
/google-analytics\.com|googletagmanager\.com\/gtag|www\.googletagmanager\.com/;
export const GOOGLE_ANALYTICS_SCRIPT_PATTERN =
/google-analytics\.com|googletagmanager\.com\/gtag/;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the third alternative matched all GTM scripts, not just GA. Fixed by narrowing to google-analytics\.com|googletagmanager\.com\/gtag which only matches GA4 (/gtag/js) and legacy GA (google-analytics.com).

48 changes: 48 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,18 @@ import { lang } from "./rules/a11y/lang.js";
import { mediaHasCaption } from "./rules/a11y/media-has-caption.js";
import { mouseEventsHaveKeyEvents } from "./rules/a11y/mouse-events-have-key-events.js";
import { nextjsAsyncClientComponent } from "./rules/nextjs/nextjs-async-client-component.js";
import { nextjsErrorBoundaryMissingUseClient } from "./rules/nextjs/nextjs-error-boundary-missing-use-client.js";
import { nextjsGlobalErrorMissingHtmlBody } from "./rules/nextjs/nextjs-global-error-missing-html-body.js";
import { nextjsImageMissingSizes } from "./rules/nextjs/nextjs-image-missing-sizes.js";
import { nextjsInlineScriptMissingId } from "./rules/nextjs/nextjs-inline-script-missing-id.js";
import { nextjsMissingMetadata } from "./rules/nextjs/nextjs-missing-metadata.js";
import { nextjsNoAElement } from "./rules/nextjs/nextjs-no-a-element.js";
import { nextjsNoClientFetchForServerData } from "./rules/nextjs/nextjs-no-client-fetch-for-server-data.js";
import { nextjsNoClientSideRedirect } from "./rules/nextjs/nextjs-no-client-side-redirect.js";
import { nextjsNoCssLink } from "./rules/nextjs/nextjs-no-css-link.js";
import { nextjsNoDefaultExportInRouteHandler } from "./rules/nextjs/nextjs-no-default-export-in-route-handler.js";
import { nextjsNoFontLink } from "./rules/nextjs/nextjs-no-font-link.js";
import { nextjsNoGoogleAnalyticsScript } from "./rules/nextjs/nextjs-no-google-analytics-script.js";
import { nextjsNoHeadImport } from "./rules/nextjs/nextjs-no-head-import.js";
import { nextjsNoImgElement } from "./rules/nextjs/nextjs-no-img-element.js";
import { nextjsNoNativeScript } from "./rules/nextjs/nextjs-no-native-script.js";
Expand Down Expand Up @@ -1318,6 +1322,28 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-error-boundary-missing-use-client",
id: "nextjs-error-boundary-missing-use-client",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsErrorBoundaryMissingUseClient,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-global-error-missing-html-body",
id: "nextjs-global-error-missing-html-body",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsGlobalErrorMissingHtmlBody,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-image-missing-sizes",
id: "nextjs-image-missing-sizes",
Expand Down Expand Up @@ -1395,6 +1421,17 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-default-export-in-route-handler",
id: "nextjs-no-default-export-in-route-handler",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsNoDefaultExportInRouteHandler,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-font-link",
id: "nextjs-no-font-link",
Expand All @@ -1406,6 +1443,17 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-google-analytics-script",
id: "nextjs-no-google-analytics-script",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsNoGoogleAnalyticsScript,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-head-import",
id: "nextjs-no-head-import",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { APP_DIRECTORY_PATTERN, ERROR_BOUNDARY_FILE_PATTERN } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { hasDirective } from "../../utils/has-directive.js";
import { normalizeFilename } from "../../utils/normalize-filename.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

export const nextjsErrorBoundaryMissingUseClient = defineRule<Rule>({
id: "nextjs-error-boundary-missing-use-client",
title: "Error boundary missing 'use client'",
tags: ["test-noise"],
requires: ["nextjs"],
severity: "error",
recommendation:
"Add `'use client'` at the top of this file — error boundaries must be Client Components to catch and render fallback UI",
create: (context: RuleContext) => ({
Program(programNode: EsTreeNodeOfType<"Program">) {
const filename = normalizeFilename(context.filename ?? "");
if (!APP_DIRECTORY_PATTERN.test(filename)) return;
if (!ERROR_BOUNDARY_FILE_PATTERN.test(filename)) return;
if (hasDirective(programNode, "use client")) return;

context.report({
node: programNode,
message:
"This error boundary silently does nothing without 'use client' — Next.js requires error.tsx to be a Client Component.",
});
},
}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { APP_DIRECTORY_PATTERN, GLOBAL_ERROR_FILE_PATTERN } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { normalizeFilename } from "../../utils/normalize-filename.js";
import { walkAst } from "../../utils/walk-ast.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const fileContainsJsxElement = (programNode: EsTreeNode, tagName: string): boolean => {
let didFind = false;
walkAst(programNode, (child: EsTreeNode) => {
if (didFind) return false;
if (
isNodeOfType(child, "JSXOpeningElement") &&
isNodeOfType(child.name, "JSXIdentifier") &&
child.name.name === tagName
) {
didFind = true;
return false;
}
});
return didFind;
};

export const nextjsGlobalErrorMissingHtmlBody = defineRule<Rule>({
id: "nextjs-global-error-missing-html-body",
title: "global-error.tsx missing <html>/<body>",
tags: ["test-noise"],
requires: ["nextjs"],
severity: "error",
recommendation:
"Wrap your error UI in `<html><body>…</body></html>` — the root layout is unmounted when global-error renders",
create: (context: RuleContext) => ({
Program(programNode: EsTreeNodeOfType<"Program">) {
const filename = normalizeFilename(context.filename ?? "");
if (!APP_DIRECTORY_PATTERN.test(filename)) return;
if (!GLOBAL_ERROR_FILE_PATTERN.test(filename)) return;

const hasHtmlTag = fileContainsJsxElement(programNode, "html");
const hasBodyTag = fileContainsJsxElement(programNode, "body");

if (!hasHtmlTag || !hasBodyTag) {
const missingTags = [!hasHtmlTag && "<html>", !hasBodyTag && "<body>"]
.filter(Boolean)
.join(" and ");

context.report({
node: programNode,
message: `global-error.tsx is missing ${missingTags} — the root layout unmounts on error, so this page renders broken HTML.`,
});
}
},
}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
APP_DIRECTORY_PATTERN,
ROUTE_HANDLER_FILE_PATTERN,
ROUTE_HANDLER_HTTP_METHODS,
} from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { normalizeFilename } from "../../utils/normalize-filename.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const programHasNamedHttpMethodExport = (programNode: EsTreeNodeOfType<"Program">): boolean => {
for (const statement of programNode.body ?? []) {
if (!isNodeOfType(statement, "ExportNamedDeclaration")) continue;
const declaration = statement.declaration;
if (
isNodeOfType(declaration, "FunctionDeclaration") &&
declaration.id?.name &&
ROUTE_HANDLER_HTTP_METHODS.has(declaration.id.name)
) {
return true;
}
if (isNodeOfType(declaration, "VariableDeclaration")) {
for (const declarator of declaration.declarations ?? []) {
if (
isNodeOfType(declarator.id, "Identifier") &&
ROUTE_HANDLER_HTTP_METHODS.has(declarator.id.name)
) {
return true;
}
}
}
for (const specifier of statement.specifiers ?? []) {
if (
isNodeOfType(specifier, "ExportSpecifier") &&
isNodeOfType(specifier.exported, "Identifier") &&
ROUTE_HANDLER_HTTP_METHODS.has(specifier.exported.name)
) {
return true;
}
}
}
return false;
};

export const nextjsNoDefaultExportInRouteHandler = defineRule<Rule>({
id: "nextjs-no-default-export-in-route-handler",
title: "Default export in route handler",
tags: ["test-noise"],
requires: ["nextjs"],
severity: "error",
recommendation:
"Replace `export default` with named HTTP method exports: `export async function GET(request) { … }`",
create: (context: RuleContext) => {
let isAppRouteHandler = false;
let programNode: EsTreeNodeOfType<"Program"> | null = null;

return {
Program(node: EsTreeNodeOfType<"Program">) {
const filename = normalizeFilename(context.filename ?? "");
isAppRouteHandler =
APP_DIRECTORY_PATTERN.test(filename) && ROUTE_HANDLER_FILE_PATTERN.test(filename);
programNode = node;
},
ExportDefaultDeclaration(node: EsTreeNodeOfType<"ExportDefaultDeclaration">) {
if (!isAppRouteHandler || !programNode) return;
if (programHasNamedHttpMethodExport(programNode)) return;

context.report({
node,
message:
"Default exports in route.ts are silently ignored — Next.js only recognizes named HTTP method exports (GET, POST, etc.).",
});
},
Comment thread
cursor[bot] marked this conversation as resolved.
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GOOGLE_ANALYTICS_SCRIPT_PATTERN } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { findJsxAttribute } from "../../utils/find-jsx-attribute.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

export const nextjsNoGoogleAnalyticsScript = defineRule<Rule>({
id: "nextjs-no-google-analytics-script",
title: "Manual Google Analytics script",
tags: ["test-noise"],
requires: ["nextjs"],
severity: "warn",
recommendation:
"Use `import { GoogleAnalytics } from '@next/third-parties/google'` for automatic optimization & smaller bundles",
create: (context: RuleContext) => ({
JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) {
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
if (node.name.name !== "script" && node.name.name !== "Script") return;

const srcAttribute = findJsxAttribute(node.attributes ?? [], "src");
if (!srcAttribute?.value) return;

const srcValue = isNodeOfType(srcAttribute.value, "Literal")
? srcAttribute.value.value
: null;

if (typeof srcValue === "string" && GOOGLE_ANALYTICS_SCRIPT_PATTERN.test(srcValue)) {
context.report({
node,
message:
"Manual Google Analytics script blocks rendering — @next/third-parties loads it with optimal strategy.",
});
}
},
}),
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ROUTE_HANDLER_HTTP_METHODS } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { normalizeFilename } from "../../utils/normalize-filename.js";
import { walkAst } from "../../utils/walk-ast.js";
Expand All @@ -7,16 +8,6 @@ import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const ROUTE_HANDLER_HTTP_METHODS = new Set([
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
]);

const STATIC_IO_FUNCTIONS = new Set([
"readFileSync",
"readFile",
Expand Down
Loading