Skip to content

fix($subscribe): prevent duplicate watcher when same callback subscribed twice - #3144

Merged
posva merged 2 commits into
vuejs:v4from
JSap0914:fix/subscribe-duplicate-callback-double-fire
Jun 30, 2026
Merged

fix($subscribe): prevent duplicate watcher when same callback subscribed twice#3144
posva merged 2 commits into
vuejs:v4from
JSap0914:fix/subscribe-duplicate-callback-double-fire

Conversation

@JSap0914

@JSap0914 JSap0914 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Closes #3143

Bug

When the same callback reference is passed to $subscribe more than once the subscriptions Set correctly stores it only once — so $patch (which iterates the Set via triggerSubscriptions) calls it only once. But $subscribe unconditionally created a new Vue watch() on every call, causing each watcher to fire the callback independently on direct state mutations (store.prop = value). N duplicate calls therefore produced N calls per mutation instead of one.

This inconsistency was introduced in #2887 when subscriptions were changed from an Array to a Set.

Fix

Before calling scope.run(() => watch(...)), check whether the callback is already in the subscriptions Set. If it is, skip creating a new watcher. The stopWatcher variable is initialised to noop so the cleanup closure remains safe to call in the duplicate case.

Verification

RUN v4.0.18 /tmp/oss-pr-loop3-20260616/pinia
Coverage enabled with v8

✓ |pinia| tests/subscriptions.spec.ts (34 tests) 60ms

Test Files 1 passed (1)
Tests 34 passed (34)
Type Errors no errors
Start at 12:19:16
Duration 1.07s (transform 197ms, setup 284ms, import 37ms, tests 60ms, environment 288ms)

% Coverage report from v8
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 38.75 | 37.02 | 28.3 | 38.11 |
pinia/src | 45.63 | 44.25 | 32.96 | 44.76 |
createPinia.ts | 55.55 | 36.36 | 50 | 56 | 32,39-44,59,73-78
diagnostics.ts | 20 | 100 | 0 | 20 | 18-40
env.ts | 100 | 100 | 100 | 100 |
global.d.ts | 0 | 0 | 0 | 0 |
...Extensions.ts | 0 | 0 | 0 | 0 |
mapHelpers.ts | 5.71 | 0 | 0 | 5.71 | 76-286,397-550
rootStore.ts | 44.44 | 6.66 | 33.33 | 37.5 | 49-57
store.ts | 49 | 48.26 | 42.55 | 48.36 | ...73,898,925-934
storeToRefs.ts | 0 | 0 | 0 | 0 | 90-115
subscriptions.ts | 100 | 100 | 80 | 100 |
types.ts | 100 | 66.66 | 100 | 100 | 24
testing/src | 0 | 0 | 0 | 0 |
diagnostics.ts | 0 | 100 | 100 | 0 | 9
...oreGetters.ts | 0 | 100 | 0 | 0 | 15
testing.ts | 0 | 0 | 0 | 0 | 113-282
-------------------|---------|----------|---------|---------|-------------------

The new tests (one per store flavour via the existing describe.each) subscribe the same spy twice and assert:

  1. Direct mutation fires spy once
  2. $patch fires spy once
  3. After unsubscribing, spy is not called again

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate store.$subscribe() registrations so the same callback no longer fires more than once per mutation.
    • Ensured unsubscribe behavior is safe and does not affect remaining valid subscriptions.
  • New Features
    • Added a dev-only diagnostic warning when the same callback is subscribed multiple times (with guidance to subscribe once or unsubscribe first).
  • Tests
    • Expanded subscription coverage to verify warning behavior and correct callback/count handling for duplicate subscriptions.
…bed twice

When a callback function is passed to `$subscribe` more than once, the
subscriptions Set correctly stores it only once (Set.add is idempotent),
but the previous code unconditionally created a new Vue watcher on every
call.  This meant that N subscriptions of the same callback reference
produced N active watchers, each independently calling the callback on
direct state mutations (store.prop = value), while $patch correctly
called it only once via triggerSubscriptions (which iterates the Set).

The inconsistency was introduced when subscriptions were changed from an
Array to a Set in vuejs#2887 to prevent duplicates: the Set-level dedup worked
for $patch but the watcher creation was never guarded.

Fix: check whether the callback is already in the Set before calling
scope.run(() => watch(...)). For a duplicate $subscribe call the guard
is a no-op (the Set entry already exists), so the returned cleanup
function still removes the callback from the Set when invoked, and the
initialised-to-noop stopWatcher makes the cleanup closure safe to call.

Add a regression test (both Options Store and Setup Store variants via
the describe.each runner) that subscribes the same spy twice and asserts
it is invoked exactly once per direct mutation and once per $patch, and
that unsubscribing stops further calls.
Copilot AI review requested due to automatic review settings June 30, 2026 03:19
@github-project-automation github-project-automation Bot moved this to 🆕 Triaging in Pinia Roadmap Jun 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65441099-a429-4c33-b61a-2831ef203cab

📥 Commits

Reviewing files that changed from the base of the PR and between 5e87e9c and bd6b73c.

📒 Files selected for processing (3)
  • packages/pinia/__tests__/subscriptions.spec.ts
  • packages/pinia/src/diagnostics.ts
  • packages/pinia/src/store.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/pinia/tests/subscriptions.spec.ts

📝 Walkthrough

Walkthrough

$subscribe now detects duplicate callback registrations, emits a diagnostic for them, and avoids creating an extra watcher. The subscription test covers the warning, single invocation per mutation, and unsubscribe behavior.

Changes

$subscribe duplicate watcher fix

Layer / File(s) Summary
Duplicate subscription diagnostic and guard
packages/pinia/src/diagnostics.ts, packages/pinia/src/store.ts
PINIA_R1007 is added to the diagnostics catalog, and $subscribe now returns noop after warning when the callback is already present in subscriptions.
Duplicate subscription test
packages/pinia/__tests__/subscriptions.spec.ts
The spec enables warning assertions and adds coverage for duplicate flush: 'sync' subscriptions, callback call counts, and unsubscribe behavior.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Two hops in the same burrow, one watcher stayed put,
A warning popped softly when the second one cut.
The Set kept its rhythm, the callback sang true,
One patch, one mutation, one carrot-cheer too.
Hooray for the bunny with dedupe in view!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing duplicate watchers from repeated $subscribe calls with the same callback.
Linked Issues check ✅ Passed The change matches #3143 by making $subscribe idempotent for duplicate callbacks and covering direct mutations and $patch behavior.
Out of Scope Changes check ✅ Passed The added diagnostic and regression tests are directly tied to the $subscribe deduplication fix and do not appear unrelated.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pinia/src/store.ts`:
- Around line 452-478: The $subscribe cleanup in store.ts is still tied to each
addSubscription call, so duplicate unsubscribe order can leave the watcher from
watch() alive after the shared subscription entry is removed. Update the
subscription handling around addSubscription, stopWatcher, and the watch() setup
so the cleanup for a given callback is shared across all $subscribe() calls and
only tears down the watcher when the last subscription is removed. Ensure the
watcher created in the alreadySubscribed block and the cleanup returned by
removeSubscription stay coordinated for duplicate unsubscribes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ba21316-2f16-444f-a006-d71acbfa2e94

📥 Commits

Reviewing files that changed from the base of the PR and between 70eacb6 and 5e87e9c.

📒 Files selected for processing (2)
  • packages/pinia/__tests__/subscriptions.spec.ts
  • packages/pinia/src/store.ts
Comment thread packages/pinia/src/store.ts Outdated

@posva posva 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.

Thanks! Simplified the implementation

@posva
posva merged commit 99dcae6 into vuejs:v4 Jun 30, 2026
1 check was pending
@github-project-automation github-project-automation Bot moved this from 🆕 Triaging to ✅ Done in Pinia Roadmap Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants