Skip to content
Merged
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/fp104-next-pages-app-path.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
---

Restrict App Router path checks to the project app or src/app directory so nested Pages Router paths do not trigger App Router rules.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Head from "next/head";
export default () => (
<Head>
<title>Page title</title>
</Head>
);
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,32 @@ describe("nextjs/nextjs-no-head-import — regressions", () => {
expect(result.diagnostics.length).toBeGreaterThan(0);
});
});

describe("App Router project-relative boundary", () => {
it.each([
"pages/app/md.tsx",
"src/pages/app/index.tsx",
"components/app/head.tsx",
"lib/app/view.tsx",
])("keeps next/head valid outside the App Router: %s", (relativePath) => {
expect(
runRule(nextjsNoHeadImport, HEAD_IMPORT_SOURCE, {
filename: `/project/${relativePath}`,
settings: { "react-doctor": { rootDirectory: "/project" } },
}).diagnostics,
).toHaveLength(0);
});
it.each([
"app/page.tsx",
"src/app/page.tsx",
"app/pages/app/page.tsx",
"src/app/(site)/page.tsx",
])("retains App Router reports: %s", (relativePath) => {
expect(
runRule(nextjsNoHeadImport, HEAD_IMPORT_SOURCE, {
filename: `/project/${relativePath}`,
settings: { "react-doctor": { rootDirectory: "/project" } },
}).diagnostics,
).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const isInProjectDirectory = (
const rootDirectory = getReactDoctorStringSetting(context.settings, "rootDirectory");
const relativeFilename = getProjectRelativeFilename(filename, rootDirectory);
if (relativeFilename !== filename) {
if (directoryPath === "app") {
return relativeFilename.startsWith("app/") || relativeFilename.startsWith("src/app/");
}
return (
relativeFilename.startsWith(`${directoryPath}/`) ||
relativeFilename.includes(directorySegment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,9 @@ import os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { runRule } from "../../test-utils/run-rule.js";
import { nextjsNoHeadImport } from "../rules/nextjs/nextjs-no-head-import.js";
import { wrapNextjsRule } from "./wrap-nextjs-rule.js";
import type { Rule } from "./rule.js";

const wrappedNoHeadImport = wrapNextjsRule(nextjsNoHeadImport);

const headImportCode = `import Head from "next/head";

export default function Page() {
return <Head><title>Title</title></Head>;
}
`;

const probeRule: Rule = {
id: "nextjs-gate-probe",
severity: "warn",
Expand All @@ -32,7 +22,7 @@ describe("wrap-nextjs-rule", () => {
let temporaryDirectory = "";

beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nextjs-gate-"));
temporaryDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rd-nextjs-gate-")));
});

afterEach(() => {
Expand All @@ -50,7 +40,7 @@ describe("wrap-nextjs-rule", () => {
});

it("fires in a package that declares next in dependencies", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: createPackageFilename({ dependencies: { next: "15.0.0" } }),
settings: rootDirectorySettings(),
});
Expand All @@ -60,7 +50,7 @@ describe("wrap-nextjs-rule", () => {
});

it("fires when next is only a devDependency", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: createPackageFilename({ devDependencies: { next: "15.0.0" } }),
settings: rootDirectorySettings(),
});
Expand All @@ -70,7 +60,7 @@ describe("wrap-nextjs-rule", () => {
});

it("stays silent in a nested workspace package that never depends on next", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: createPackageFilename({ dependencies: { react: "19.0.0", vite: "6.0.0" } }),
settings: rootDirectorySettings(),
});
Expand All @@ -80,7 +70,7 @@ describe("wrap-nextjs-rule", () => {
});

it("fires in a non-next package when no project root is provided", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: createPackageFilename({ dependencies: { react: "19.0.0" } }),
});

Expand All @@ -89,7 +79,7 @@ describe("wrap-nextjs-rule", () => {
});

it("fires below the root when the nearest manifest is a marker without dependencies", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: createPackageFilename({ type: "module" }),
settings: rootDirectorySettings(),
});
Expand All @@ -99,7 +89,7 @@ describe("wrap-nextjs-rule", () => {
});

it("fires when there is no discoverable package manifest", () => {
const result = runRule(wrappedNoHeadImport, headImportCode, {
const result = runRule(wrappedProbe, "export {};", {
filename: path.join(temporaryDirectory, "standalone", "app", "page.tsx"),
settings: rootDirectorySettings(),
});
Expand Down
Loading