Skip to content

feat(lint/js): add noNestedPromises#9019

Merged
dyc3 merged 1 commit intomainfrom
dyc3/no-nested-promises
Feb 11, 2026
Merged

feat(lint/js): add noNestedPromises#9019
dyc3 merged 1 commit intomainfrom
dyc3/no-nested-promises

Conversation

@dyc3
Copy link
Copy Markdown
Contributor

@dyc3 dyc3 commented Feb 10, 2026

Summary

This PR adds a rule from eslint-plugin-promise, a new rule source. This plugin provides a bunch of Promise-specific lints that can be pretty valuable, particularly to beginner programmers (IMO).

Source rule: https://github.com/eslint-community/eslint-plugin-promise/blob/main/docs/rules/no-nesting.md

This rule specifically applies to Promises, but I intentionally chose not to use type information because the source rule also does not use type information to know if its flagging a promise.

Generated by sonnet 4.5, but I tweaked it a bit manually afterwards.

Test Plan

copied all the tests from the source rule.

Docs

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 10, 2026

🦋 Changeset detected

Latest commit: d7fc389

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added A-CLI Area: CLI A-Project Area: project A-Linter Area: linter L-JavaScript Language: JavaScript and super languages A-Diagnostic Area: diagnostocis labels Feb 10, 2026
@dyc3 dyc3 marked this pull request as draft February 10, 2026 00:06
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Feb 10, 2026

Merging this PR will not alter performance

✅ 58 untouched benchmarks
⏩ 95 skipped benchmarks1


Comparing dyc3/no-nested-promises (d7fc389) with main (9d4cfa3)

Open in CodSpeed

Footnotes

  1. 95 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

Walkthrough

Adds a new JavaScript lint rule noNestedPromises that detects nested .then()/.catch() callbacks which can be flattened, with an exception when the inner callback references outer-scope variables. Integrates the rule into the Biome lint framework (exports NoNestedPromises), introduces serialisable NoNestedPromisesOptions, adds tests (valid/invalid cases), and extends RuleSource with a new EslintPromise(&str) variant including Display, namespace and URL mapping.

Possibly related PRs

Suggested reviewers

  • ematipico
  • unvalley
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and specifically describes the main change: adding a new noNestedPromises lint rule for JavaScript.
Description check ✅ Passed The PR description clearly explains the motivation (adding a new rule from eslint-plugin-promise), references the source rule, and provides context about implementation choices and testing approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dyc3/no-nested-promises

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs`:
- Around line 1-79: The rule NoNestedPromises currently sets type Options = (),
but there is an existing options struct NoNestedPromisesOptions in
biome_rule_options; change the rule's Options associated type from () to
NoNestedPromisesOptions so the rule uses the proper config/schema and option
parsing. Update the impl Rule for NoNestedPromises to reference
NoNestedPromisesOptions (and adjust any uses of the options value in methods if
needed) so configuration is correctly wired.

In `@crates/biome_rule_options/src/no_nested_promises.rs`:
- Around line 1-6: Add a one-line inline rustdoc above the
NoNestedPromisesOptions struct describing what the options configure (e.g.,
"Options for the `no-nested-promises` rule."), matching the style used by other
rule option structs; ensure the doc is a single-line /// comment placed
immediately above the pub struct NoNestedPromisesOptions {} declaration so it
appears in generated docs and follows existing guidelines.

Comment thread crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs Outdated
Comment thread crates/biome_rule_options/src/no_nested_promises.rs
@dyc3 dyc3 force-pushed the dyc3/no-nested-promises branch 2 times, most recently from 71616e9 to a097ee0 Compare February 10, 2026 13:16
@dyc3 dyc3 marked this pull request as ready for review February 10, 2026 13:16
Copy link
Copy Markdown
Contributor Author

dyc3 commented Feb 10, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs`:
- Around line 152-174: The ancestor walk in is_promise_callback currently
matches any ancestor call whose member name is "then"/"catch" even if the
function is nested deeper; update is_promise_callback to, after locating a
JsCallExpression whose callee is a .then/.catch name, verify that the function
node is a direct argument of that call (e.g., walk its JsCallExpression
arguments / JsCallArgument children and check one of their expression nodes
equals func.syntax() or the function's immediate JsCallArgument node) so only
direct promise callbacks return true; keep references to is_promise_callback,
AnyJsFunction, JsCallExpression, and JsCallArgument to locate the change.

In `@crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/valid.js`:
- Around line 14-15: The two lines use an invalid two-argument form for
Promise.prototype.catch: locate the calls to doThing().catch(null, function() {
... }) and either remove them or change them to the intended form — if you meant
to register a rejection handler only, replace with doThing().catch(function() {
... }), or if you intended the then(null, onRejected) pattern, replace with
doThing().then(null, function() { ... }); update both occurrences accordingly
(the two doThing().catch(...) expressions).
🧹 Nitpick comments (2)
crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs (1)

227-237: Consider using AstNode::ancestors() for cleaner traversal.

The manual parent-walking loop works, but biome_rowan provides ancestors() which could simplify this:

fn is_descendant_of(node: &JsSyntaxNode, potential_ancestor: &JsSyntaxNode) -> bool {
    node.ancestors().any(|ancestor| ancestor == *potential_ancestor)
}
crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/invalid.js (1)

19-23: Clarify test comment.

The comment says "references vars in closure but doesn't use them" which is a bit confusing. The outer callback receives a and passes it to getB(a), but the inner .then(b => getC(b)) only uses its own parameter b, not the outer a. Perhaps clarify:

-// references vars in closure but doesn't use them
+// inner callback doesn't reference outer scope variable 'a'

Comment on lines +152 to +174
/// Check if a function is a direct callback to .then() or .catch()
fn is_promise_callback(func: &AnyJsFunction) -> bool {
// Walk up the tree to find a call expression
// The structure is: Function -> JsCallArgument (or similar) -> ... -> JsCallExpression
func.syntax()
.ancestors()
.skip(1) // skip self
.find_map(|node| {
JsCallExpression::cast_ref(&node)?
.callee()
.ok()?
.as_js_static_member_expression()?
.member()
.ok()?
.as_js_name()?
.value_token()
.ok()
})
.is_some_and(|token| {
let text = token.text_trimmed();
text == "then" || text == "catch"
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual code around lines 152-174
sed -n '140,190p' crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs

Repository: biomejs/biome

Length of output: 1871


🏁 Script executed:

# Search for test files related to noNestedPromises
find crates/biome_js_analyze/tests -name "*noNestedPromises*" -o -name "*nested*promise*"

Repository: biomejs/biome

Length of output: 117


🏁 Script executed:

# Check for test cases with nested or unrelated callbacks
rg -n "unrelated|setTimeout|forEach" crates/biome_js_analyze/tests/ --type rust -A 3 -B 3 | head -100

Repository: biomejs/biome

Length of output: 778


🏁 Script executed:

# List all files in the test directory
ls -la crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/

Repository: biomejs/biome

Length of output: 420


🏁 Script executed:

# Read the test specification file
cat crates/biome_js_analyze/tests/specs/nursery/noNestedPromises.jsonc 2>/dev/null || cat crates/biome_js_analyze/tests/specs/nursery/noNestedPromises.json 2>/dev/null || find crates/biome_js_analyze/tests/specs/nursery -name "*noNestedPromises*" -type f

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Look for JavaScript test input files
find crates/biome_js_analyze/tests/specs/nursery/noNestedPromises -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx"

Repository: biomejs/biome

Length of output: 198


🏁 Script executed:

# Examine the test cases
cat crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/invalid.js

Repository: biomejs/biome

Length of output: 616


🏁 Script executed:

cat crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/valid.js

Repository: biomejs/biome

Length of output: 1523


🏁 Script executed:

# Check if there are any test snapshots that show unexpected behavior
cat crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/invalid.js.snap | head -100

Repository: biomejs/biome

Length of output: 3126


🏁 Script executed:

# Search for other test references to understand if this scenario is covered elsewhere
rg -n "unrelated\|nested.*forEach\|nested.*setTimeout" crates/biome_js_analyze/tests/specs/nursery/ -A 3

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Create a test case to verify the false positive concern
cat > /tmp/test_false_positive.js << 'EOF'
// This should NOT be flagged as a nested promise
something.then(() => {
  unrelated(() => {
    nested.then(() => {})
  })
})
EOF

# Check what the rule currently does
cat /tmp/test_false_positive.js

Repository: biomejs/biome

Length of output: 183


🏁 Script executed:

# Let's trace through the ancestor logic more carefully by examining the actual code flow
# Look at how the function uses the results
sed -n '130,160p' crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs

Repository: biomejs/biome

Length of output: 1146


🏁 Script executed:

# Check if there's a direct argument check anywhere
rg -n "direct\|argument\|parent" crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs -B 2 -A 2

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Let's understand the full rule logic to see if the concern is valid
# Look at where is_promise_callback is called and how it's used
rg -n "is_promise_callback\|find_parent_promise_callback" crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs -B 3 -A 3

Repository: biomejs/biome

Length of output: 39


🏁 Script executed:

# Let's trace the visit logic - when is a nested promise detected?
sed -n '80,130p' crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs

Repository: biomejs/biome

Length of output: 2144


🏁 Script executed:

# Let me trace through the exact scenario from the review to understand the AST structure
# Look at the full file to understand the complete context
wc -l crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs

Repository: biomejs/biome

Length of output: 123


🏁 Script executed:

# Get the complete file content to understand the full logic
cat crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs

Repository: biomejs/biome

Length of output: 8364


The concern is valid: is_promise_callback() can incorrectly match distant promise chain ancestors.

The ancestor walk in is_promise_callback doesn't verify the function is a direct argument of the matched call. For instance:

something.then(() => {
  unrelated(() => {
    nested.then(() => {})
  })
})

The inner arrow is passed to unrelated(), but its ancestor chain includes something.then() up the tree. The function would incorrectly identify it as a promise callback, flagging nested.then() as a false positive.

The existing test suite doesn't cover this nested non-promise-callback scenario, so the bug isn't caught. The fix would require verifying the function is a direct first argument of the matched .then()/.catch() call, not just an ancestor.

🤖 Prompt for AI Agents
In `@crates/biome_js_analyze/src/lint/nursery/no_nested_promises.rs` around lines
152 - 174, The ancestor walk in is_promise_callback currently matches any
ancestor call whose member name is "then"/"catch" even if the function is nested
deeper; update is_promise_callback to, after locating a JsCallExpression whose
callee is a .then/.catch name, verify that the function node is a direct
argument of that call (e.g., walk its JsCallExpression arguments /
JsCallArgument children and check one of their expression nodes equals
func.syntax() or the function's immediate JsCallArgument node) so only direct
promise callbacks return true; keep references to is_promise_callback,
AnyJsFunction, JsCallExpression, and JsCallArgument to locate the change.

Comment on lines +14 to +15
doThing().catch(null, function() { return 4 })
doThing().catch(null, function() { throw 4 })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unusual catch syntax.

Promise.prototype.catch only accepts a single callback argument. The catch(null, function() {...}) pattern passes two arguments which is semantically incorrect (the second argument is ignored). This won't break the test but may confuse readers.

Was this intended to mirror the .then(null, onRejected) pattern? If so, consider removing these lines or updating them to valid catch usage.

🤖 Prompt for AI Agents
In `@crates/biome_js_analyze/tests/specs/nursery/noNestedPromises/valid.js` around
lines 14 - 15, The two lines use an invalid two-argument form for
Promise.prototype.catch: locate the calls to doThing().catch(null, function() {
... }) and either remove them or change them to the intended form — if you meant
to register a rejection handler only, replace with doThing().catch(function() {
... }), or if you intended the then(null, onRejected) pattern, replace with
doThing().then(null, function() { ... }); update both occurrences accordingly
(the two doThing().catch(...) expressions).

@dyc3 dyc3 marked this pull request as draft February 10, 2026 14:05
@dyc3 dyc3 force-pushed the dyc3/no-nested-promises branch from a097ee0 to d7fc389 Compare February 10, 2026 14:17
@dyc3 dyc3 marked this pull request as ready for review February 10, 2026 15:25
@dyc3 dyc3 merged commit 043b67c into main Feb 11, 2026
22 checks passed
@dyc3 dyc3 deleted the dyc3/no-nested-promises branch February 11, 2026 15:54
@github-actions github-actions Bot mentioned this pull request Feb 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-CLI Area: CLI A-Diagnostic Area: diagnostocis A-Linter Area: linter A-Project Area: project L-JavaScript Language: JavaScript and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants