Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
18 changes: 18 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,21 @@ 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/;

export const OG_IMAGE_FILE_PATTERN = /\/(opengraph-image|twitter-image)\.(tsx?|jsx?)$/;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
84 changes: 84 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,21 +97,28 @@ 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 { nextjsNoEdgeOgRuntime } from "./rules/nextjs/nextjs-no-edge-og-runtime.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";
import { nextjsNoPolyfillScript } from "./rules/nextjs/nextjs-no-polyfill-script.js";
import { nextjsNoRedirectInTryCatch } from "./rules/nextjs/nextjs-no-redirect-in-try-catch.js";
import { nextjsNoScriptInHead } from "./rules/nextjs/nextjs-no-script-in-head.js";
import { nextjsNoSideEffectInGetHandler } from "./rules/nextjs/nextjs-no-side-effect-in-get-handler.js";
import { nextjsNoUseSearchParamsWithoutSuspense } from "./rules/nextjs/nextjs-no-use-search-params-without-suspense.js";
import { nextjsNoVercelOgImport } from "./rules/nextjs/nextjs-no-vercel-og-import.js";
import { noAccessKey } from "./rules/a11y/no-access-key.js";
import { noAdjustStateOnPropChange } from "./rules/state-and-effects/no-adjust-state-on-prop-change.js";
import { noAriaHiddenOnFocusable } from "./rules/a11y/no-aria-hidden-on-focusable.js";
Expand Down Expand Up @@ -1318,6 +1325,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 +1424,28 @@ 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-edge-og-runtime",
id: "nextjs-no-edge-og-runtime",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsNoEdgeOgRuntime,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-font-link",
id: "nextjs-no-font-link",
Expand All @@ -1406,6 +1457,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 Expand Up @@ -1461,6 +1523,17 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-script-in-head",
id: "nextjs-no-script-in-head",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsNoScriptInHead,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-side-effect-in-get-handler",
id: "nextjs-no-side-effect-in-get-handler",
Expand All @@ -1483,6 +1556,17 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
{
key: "react-doctor/nextjs-no-vercel-og-import",
id: "nextjs-no-vercel-og-import",
source: "react-doctor",
originallyExternal: false,
rule: {
...nextjsNoVercelOgImport,
framework: "nextjs",
category: "Bugs",
},
},
{
key: "react-doctor/no-access-key",
id: "no-access-key",
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,38 @@
import { APP_DIRECTORY_PATTERN, GLOBAL_ERROR_FILE_PATTERN } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { fileContainsJsxElements } from "../../utils/file-contains-jsx-elements.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";

const REQUIRED_TAGS = ["html", "body"] as const;

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 foundTags = fileContainsJsxElements(programNode, REQUIRED_TAGS);
const missingTags = REQUIRED_TAGS.filter((tag) => !foundTags.has(tag)).map(
(tag) => `<${tag}>`,
);

if (missingTags.length > 0) {
context.report({
node: programNode,
message: `global-error.tsx is missing ${missingTags.join(" and ")}. 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,49 @@
import { OG_IMAGE_FILE_PATTERN } from "../../constants/nextjs.js";
import { defineRule } from "../../utils/define-rule.js";
import { normalizeFilename } from "../../utils/normalize-filename.js";
import { isNodeOfType } from "../../utils/is-node-of-type.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 nextjsNoEdgeOgRuntime = defineRule<Rule>({
id: "nextjs-no-edge-og-runtime",
title: "Edge runtime in OG image route",
tags: ["test-noise"],
requires: ["nextjs"],
severity: "warn",
recommendation:
"Remove `export const runtime = 'edge'` from OG image files. The default Node.js runtime supports more fonts and APIs",
create: (context: RuleContext) => {
let isOgImageFile = false;

return {
Program() {
const filename = normalizeFilename(context.filename ?? "");
isOgImageFile = OG_IMAGE_FILE_PATTERN.test(filename);
},
ExportNamedDeclaration(node: EsTreeNodeOfType<"ExportNamedDeclaration">) {
if (!isOgImageFile) return;

const declaration = node.declaration;
if (!isNodeOfType(declaration, "VariableDeclaration")) return;

for (const declarator of declaration.declarations ?? []) {
if (!isNodeOfType(declarator, "VariableDeclarator")) continue;
if (!isNodeOfType(declarator.id, "Identifier")) continue;
if (declarator.id.name !== "runtime") continue;

const initValue = isNodeOfType(declarator.init, "Literal") ? declarator.init.value : null;

if (initValue === "edge") {
context.report({
node,
message:
"Edge runtime limits OG image generation. Node.js runtime supports more fonts, filesystem access, and larger response sizes.",
});
}
}
},
};
},
});
Loading
Loading