Skip to content

fix(nextjs): scope useSearchParams/Suspense rule to page files only (#695)#714

Merged
aidenybai merged 13 commits into
mainfrom
devin/1780786984-fix-use-search-params-suspense-false-positive
Jun 7, 2026
Merged

fix(nextjs): scope useSearchParams/Suspense rule to page files only (#695)#714
aidenybai merged 13 commits into
mainfrom
devin/1780786984-fix-use-search-params-suspense-false-positive

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Jun 6, 2026

Summary

Fixes #695nextjs-no-use-search-params-without-suspense false positive on non-page components, plus new cross-file detection.

Why

Non-page component files are composed by parents that provide the Suspense boundary. Flagging them produces noise. But silently skipping them loses the diagnostic entirely — the page that renders them without Suspense is the real bug site.

Before (false positive on every component file):

// components/sign-in-button.tsx — Suspense is in parent page
export const SignInButton = () => {
  const params = useSearchParams(); // ⚠️ false positive
};

After — two-layer detection:

// 1. Same-file: page.tsx calls useSearchParams() directly → flagged
// 2. Cross-file: page.tsx renders <SearchBar /> without Suspense,
//    and SearchBar calls useSearchParams() → flagged at render site

What changed

  • Scoped the rule to fire only on PAGE_OR_LAYOUT_FILE_PATTERN files.
  • Cross-file detection: in page/layout files, for each <ImportedComponent /> NOT inside a <Suspense> ancestor, resolves the relative import via resolveRelativeImportPathparseSourceFilefindExportedFunctionBody, then checks if the component body calls useSearchParams(). Reports at the render site with a component-specific message.
  • Extracted helpers: astContainsUseSearchParams, detectSuspenseAwareness, collectRelativeImports, isInsideSuspenseBoundary, exportedComponentUsesSearchParams.
  • Added ImportedComponentEntry interface to carry both source and exportedName.
  • Added cross-file unit tests (5 cases) and integration fixture (cross-file/page.tsx + cross-file/search-consumer.tsx).
  • Changeset (patch) for oxlint-plugin-react-doctor.

Test plan

  • npx vp test run ...nextjs-no-use-search-params-without-suspense.cross-file.test.ts — 5/5 passed
  • npx vp test run ...run-oxlint/nextjs.test.ts — 33/33 passed
  • turbo run typecheck --filter=oxlint-plugin-react-doctor — passed

Link to Devin session: https://app.devin.ai/sessions/62d04f36734d4500b609e250430b8469
Requested by: @aidenybai


Note

Medium Risk
Adds filesystem walks, tsconfig parsing, and cross-file AST resolution on lint runs; behavior changes which files get diagnostics and could miss edge cases, but bounds and null-safe resolution limit blast radius.

Overview
Fixes #695 by tightening nextjs-no-use-search-params-without-suspense: it now runs only on App Router page/layout files, so leaf components that call useSearchParams() are no longer flagged.

On those page/layout files, the rule adds cross-file checks: for each rendered imported component not inside a local <Suspense> ancestor, it resolves the export (relative paths, tsconfig @/ aliases, barrel re-exports) and reports at the JSX site if that component body uses useSearchParams(). Ancestor layout.tsx files with Suspense (including <React.Suspense> and aliased imports) suppress diagnostics for the whole route segment.

Shared infrastructure: resolveTsconfigAliasPath, resolveModulePath, extracted resolveCrossFileFunctionExport (also used by no-mutating-reducer-state for @/ imports), astMentionsSuspense, hasAncestorSuspenseLayout, and bounded directory-walk constants. Extensive unit and integration tests plus a patch changeset for oxlint-plugin-react-doctor.

Reviewed by Cursor Bugbot for commit 92f255a. Bugbot is set up for automated code reviews on this repo. Configure here.

…695)

Non-page component files are expected to be composed with <Suspense>
by their consumers. Firing on every file that lacks an in-file Suspense
produced false positives when the boundary lives in a parent module.

Now the rule only reports on files matching PAGE_FILE_PATTERN (page.tsx),
where the developer IS responsible for providing their own boundary.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@714
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@714
npm i https://pkg.pr.new/react-doctor@714

commit: 92f255a

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 6, 2026

No React Doctor issues found. 🎉

Reviewed by React Doctor for commit 92f255a.

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

devin-ai-integration Bot and others added 5 commits June 6, 2026 23:09
Layout files are framework entry points with the same useSearchParams +
Suspense requirement. Use PAGE_OR_LAYOUT_FILE_PATTERN to cover both.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
When a page/layout renders an imported component NOT wrapped in
<Suspense>, resolve the import and check whether the exported
component calls useSearchParams(). Reports at the render site.

Uses existing cross-file infrastructure: parseSourceFile,
resolveRelativeImportPath, findExportedFunctionBody.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Address review on #714:
- Drop the whole-file fallback in the cross-file check. When an export
  doesn't bind to a function (memo()/forwardRef()/class/barrel re-export)
  we now bail instead of scanning the whole module, which previously
  flagged a component for an UNRELATED sibling export's useSearchParams()
  call (false positive).
- Recognize <React.Suspense> (member form) and aliased
  `import { Suspense as X }` wrappers in the per-element boundary check,
  removing false positives on correctly-wrapped pages.
- Restore the dropped Suspense-heuristic rationale and document the
  relative-only / barrel cross-file limitations as deliberate
  false-negative-over-false-positive trade-offs.
- Reuse resolveImportedExportName instead of duplicating import-specifier
  name resolution.
- Import useSearchParams from next/navigation in the nextjs-app fixtures
  so the integration tests exercise the real Next hook, not a local stub.
…useSearchParams rule (#714)

Close the deferred cross-file gaps in nextjs-no-use-search-params-without-suspense:
- H1: resolve tsconfig `@/` path aliases (+ baseUrl) so alias-imported
  consumers are checked, not just relative imports (the dominant Next.js
  convention). New resolve-tsconfig-alias (JSONC + extends) + combined
  resolve-module-path (relative -> alias).
- M4: follow barrel / re-export chains by reusing the canonical
  cross-file resolver. Extracted resolve-cross-file-function-export from
  the reducer rule (now shared by both), deleting the duplicate.
- Suppress false positives when an ancestor layout.tsx wraps `{children}`
  in <Suspense> (new find-ancestor-suspense-layout) since that boundary
  covers the whole page.
- Extract the file-level Suspense heuristic into ast-mentions-suspense,
  shared by the rule and the layout walk.

Tests: tsconfig-alias unit tests (baseUrl / no-baseUrl / JSONC / extends)
plus cross-file tests for alias, barrel, and ancestor-layout cases.
CodeQL flagged `target.replace("*", capture)` as incomplete string
replacement (only the first `*`). A tsconfig `paths` target has at most
one `*`, so behavior is unchanged, but replaceAll is complete + robust
and clears the high-severity code-scanning alert.
aidenybai added 2 commits June 6, 2026 19:53
…xtended tsconfig paths

Address Bugbot review on the cross-file work:
- `astMentionsSuspense` now also matches the `<React.Suspense>` member
  form, so an ancestor `layout.tsx` that wraps `{children}` via a
  namespace-imported `React.Suspense` is recognized as a boundary (was a
  false positive).
- `readResolvedTsconfig` now inherits `paths` from the `extends` chain
  when the child config has no `paths` field (a present `paths` — even
  empty — still replaces, matching TypeScript). A child declaring only
  `baseUrl` no longer drops the base's `@/` aliases.

Tests: ancestor `<React.Suspense>` layout, extends-inherits-paths, and
explicit-empty-paths-replaces cases.
…lution

Drop the relative-only guard in resolveReducerFunction so imported
reducers behind tsconfig path aliases (`@/reducers/x`) are followed too.
The shared resolveModulePath returns null for bare node-module
specifiers that match no alias, so packaged code (`react-redux`, etc.)
is still skipped without an explicit guard.

Tests: mutation flagged through an `@/`-aliased reducer; a bare
node-module import that matches no alias is not followed.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f7cd4b3. Configure here.

Comment thread packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.ts Outdated
The per-directory cache in findNearestTsconfig wasn't mtime-checked, so a
long-lived process (the language server) could serve stale `@/` alias
resolution after a tsconfig edit or a newly-added config. Removed it and
rely on the mtime-keyed per-file cache; the directory walk re-stats
cheaply and reuses parsed configs. Added a regression test that rewrites
a tsconfig mid-process and asserts the new mapping is picked up.
@aidenybai aidenybai merged commit ee9ab33 into main Jun 7, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False Positive: Bugs: useSearchParams without Suspense

2 participants