Skip to content

feat(prefer-single-binding): add rule - #176

Open
Olovyannikov wants to merge 15 commits into
effector:masterfrom
Olovyannikov:new/prefer-single-binding
Open

feat(prefer-single-binding): add rule#176
Olovyannikov wants to merge 15 commits into
effector:masterfrom
Olovyannikov:new/prefer-single-binding

Conversation

@Olovyannikov

@Olovyannikov Olovyannikov commented Dec 14, 2025

Copy link
Copy Markdown
Contributor

Closes #116

Why is this important?

Performance

Each useUnit call creates its own subscription management overhead. Combining them reduces:

  • Number of hook calls
  • Subscription management overhead
  • Re-render coordination complexity

Code clarity

A single useUnit call makes it easier to:

  • See all dependencies at a glance
  • Understand component's reactive logic
  • Maintain and refactor code

Example

// correct
const Component = () => {
  const [value, setValue] = useUnit([$store, storeUpdated]);
}

// incorrect
const Component = () => {
  const [value] = useUnit([$store]);
  const [setValue] = useUnit([storeUpdated]);
}

Separate stores and events calls

If you want to separate stores and events as independent groups, you can use option separation

When set to allow, allows separate useUnit calls for stores and events, but still enforces combining multiple calls within each group.

Example

// correct
const Component = () => {
  const [value, setValue] = useUnit([$store, storeUpdated]);
}

// if `separation` flag is 'enforce':
const Component = () => {
  const [value, setValue] = useUnit([$store, storeUpdated]);
  
   // will be transformed to:
   
  const [value] = useUnit([$store]);
  const [setValue] = useUnit([storeUpdated])
}

// also correct
const Component = () => {
  const [value] = useUnit([$store]);
  const [setValue] = useUnit([storeUpdated]);
}

// incorrect
const Component = () => {
  const [value] = useUnit([$store]);
  const [setValue] = useUnit([storeUpdated]);
  const [setAnotherValue] = useUnit([thirdEvent]);
}

@netlify

netlify Bot commented Dec 14, 2025

Copy link
Copy Markdown

Deploy Preview for eslint-plugin ready!

Name Link
🔨 Latest commit f08c2c6
🔍 Latest deploy log https://app.netlify.com/projects/eslint-plugin/deploys/6aa1038fb17580000886b9a3
😎 Deploy Preview https://deploy-preview-176--eslint-plugin.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch 4 times, most recently from 7edd618 to 15906fe Compare December 14, 2025 11:20
@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch 2 times, most recently from e12cac1 to 82797c1 Compare March 4, 2026 08:10
@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch from 82797c1 to 89651b2 Compare April 10, 2026 08:26
@Olovyannikov Olovyannikov changed the title feat(prefer-single-binding): add rule [WIP] feat(prefer-single-binding): add rule Apr 13, 2026
@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch 2 times, most recently from 25440ef to c157176 Compare April 14, 2026 09:48
@Olovyannikov Olovyannikov changed the title [WIP] feat(prefer-single-binding): add rule feat(prefer-single-binding): add rule Apr 14, 2026
@Olovyannikov

Copy link
Copy Markdown
Contributor Author

@kireevmp can you review this code please?
I did it similarly to #175. I removed the unit type detection using heuristics and replaced it with type-check. And updated the test cases at the same time.

@kireevmp kireevmp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @Olovyannikov! I skimmed through the PR and before we dive into actual implementation specifics, let's work out what this rule should check for and what we'd consider a violation. Right now it seems there are a few edge cases that need considering.

Comment on lines +314 to +323
schema: [
{
type: "object",
properties: {
allowSeparateStoresAndEvents: { type: "boolean", default: false },
enforceStoresAndEventsSeparation: { type: "boolean", default: false },
},
additionalProperties: false,
},
],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

allow and enforce settings are not actually independent, because you can't enforce separation while also disallowing it. These are better merged into a string flag, i.e. separation: "forbid" | "allow" | "enforce"

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.

Yes, you're right. I made a string flag now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You added three items here: forbid, allow and enforce like I suggested. Can you explain how allow is different during code generation from forbid and enforce 'separation"? And how would the user know clearly why allow works this way?

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.

The three modes differ only in what code generation is allowed to touch:

  • forbid — merges every call in the component into one, regardless of unit type.
  • allow — merges calls that are entirely one type (two store calls → one store call). It never touches a mixed call and never moves a unit across types, so it removes same-type duplication only.
  • enforce — does what allow does and splits a mixed call into one call per type. End state: exactly one call per unit type.

So allow isn't "separation rules off" — it's "separating by type is your choice, duplicating within a type isn't". Internally allow and enforce share the same grouping pass; enforce just adds the mixed-call split on top.

On discoverability: the rule doc now leads the separation section with a table and spells the code-generation difference out before the per-mode examples, so the distinction is stated rather than inferred from samples.

Value Result Forbids
"forbid" exactly one useUnit call per component every extra call
"allow" one call per unit type at most; a mixed call is left as-is only same-type duplicate calls
"enforce" exactly one call per unit type same-type duplicates and mixed calls

Leaving this open in case the naming still reads off to you — allow is the one I'm least attached to.

Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/ruleset.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
@Olovyannikov
Olovyannikov requested a review from kireevmp April 16, 2026 18:27
@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch from a7b21d9 to 2898b5b Compare April 17, 2026 08:11

@kireevmp kireevmp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @Olovyannikov – thanks for taking the time to come back to polish. I see good progress here, I think there's a solid foundation now with normalization concept in place. What's left is to apply it consistently everywhere across the rule, to actually use that structure. The mental model here seems to be correct, which is great news.

If you're up to do another pass, I'd appreciate you also reviewing the test suite. Due to large rule size, under-employed normalization and ultimately duplicated code paths, the coverage is below 80% and the suite may not cover every corner case, something we really need here to be sure rule is sound.

Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
/** Unit expression nodes of a call, regardless of whether it can be normalized (used for hoisting analysis). */
function unitExpressionNodes(call: UseUnitCall): Node.Expression[] {
const argument = call.init.arguments[0]
if (!argument || argument.type === NodeType.SpreadElement) return []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider narrowing the selector on ESLint side to only include useUnit calls with Object, Array expressions or Identifier as first argument. May save couple of ifs across the rule.

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.

Partly done. The selector now pins arity and callee shape:

VariableDeclarator:has(> CallExpression.init[arguments.length=1][callee.type=Identifier])

I stopped short of pinning the argument type, though. Identifier isn't sufficient for the plain form — useUnit(CartModel.$cart) is a MemberExpression, and model-namespaced access like that is the common real-world shape. Covering it would mean :matches([arguments.0.type=ObjectExpression], [arguments.0.type=ArrayExpression], [arguments.0.type=Identifier], [arguments.0.type=MemberExpression], ...), an open-ended list that reads worse than the two checks it removes — and the argument still has to be type-checked in the visitor regardless, since that's what excludes @@unitShape.

Leaving this open: happy to add the :matches(...) narrowing if you'd still prefer it on the ESQuery side.

Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/rules/prefer-single-binding/prefer-single-binding.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/ruleset.ts Outdated
@Olovyannikov
Olovyannikov requested a review from kireevmp June 15, 2026 08:19
…inding

Collapse the three parallel traversals (extractBindings, unitExpressionNodes,
generateSeparationFix) into one analyze() pass that yields a single primitive:
an identifier bound to the unit expression passed to useUnit. Every helper now
consumes that structure, and source text is read only while building a fix.

Resolve the soundness issues raised in review:
- module units declared below the component are no longer a false TDZ hazard;
  only locals declared at/after the anchor block hoisting
- multi-declarator statements, let/var and type-annotated bindings are reported
  without a suggestion instead of producing a crashing or invalid fix
- the split fix keeps units whose type is undetermined instead of dropping them
- duplicate destructuring keys no longer silently drop a binding
- merging finds mergeable calls below one that cannot be hoisted
- enforce now merges same-type calls as well as splitting mixed ones

Drop the computed type aliases, the unsound `as Expression` cast and the
redundant `as const`/type guards in favour of the built-in ts-eslint types.

Tests cover the new edge cases; branch coverage rises from 79% to 89%.
…e cases

Add a comparison of forbid/allow/enforce code generation, spell out which calls
are ignored (@@unitShape, non-unit args, temporal dead zone) and which are
reported without a suggestion (multi-declarator, let, type annotations, etc.).
prefer-single-binding stays available as effector/prefer-single-binding but is
no longer bundled in a preset — removing a preset after release is painful, so
hold off on adding one. Also order prefer-single-binding before prefer-useUnit.
The root `tsconfig.fixture.json` this suite pointed at was removed in effector#184,
which moved every rule test onto `createRuleTester` and the on-disk fixture
project in `src/testing/fixture`. Follow that migration.
Every other rule page carries one; without it the rule renders with an
empty description in the docs rule table.
@Olovyannikov
Olovyannikov force-pushed the new/prefer-single-binding branch from 3b4f69d to 53347fb Compare September 9, 2026 06:56
…nction selectors

ESLint strips a trailing `:exit` before parsing the selector, so a comma
list carries it for every branch.
@Olovyannikov

Copy link
Copy Markdown
Contributor Author

Rebased onto current master and went back through the review. @kireevmp — this is ready for another pass when you have time.

Rebase. Picked up eslint@10 support, the naming-convention changes and #184. The one adaptation needed was the test suite: it pointed at the root tsconfig.fixture.json, which #184 removed, so it now uses createRuleTester({ jsx: true }) and the on-disk fixture project like every other rule. Also added the missing changeset and the doc frontmatter description — this was the only rule page without one, so it rendered with an empty description in the rules table.

Review threads. I replied inline on all 25 and resolved the 21 that the normalization refactor closed out. Four are left open for you:

  • the allow semantics question — answered, but I'd like your read on whether the naming still works
  • ESQuery narrowing of the call argument — explained why I stopped where I did; happy to go further
  • pre-checking inside analyze() vs. the visitor
  • scope tracking during traversal vs. the retroactive hoist check

On the coverage concern from your last summary: the rule file is at 93.5% statements / 89.1% branches / 100% functions / 95.7% lines across 50 test cases, and it shrank from ~500 to 380 lines. Every soundness issue you listed in the "three distinct traversals" thread now has a named test.

Current state: 399 tests pass, lint and tsc --noEmit are clean, and the docs site builds.

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.

Rule: prefer-single-binding

2 participants