Skip to content

Commit 3bfe9e5

Browse files
committed
fix(subscribe): avoid $subscriptions with $patch
Fix #908
1 parent 9191743 commit 3bfe9e5

File tree

4 files changed

+132
-13
lines changed

4 files changed

+132
-13
lines changed

packages/docs/core-concepts/state.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@ cartStore.$subscribe((mutation, state) => {
151151
})
152152
```
153153

154-
By default, _state subscriptions_ are bound to the component where they are added (if the store is inside a component's `setup()`). Meaning, they will be automatically removed when the component is unmounted. If you want to keep them after the component is unmounted, pass `true` as the second argument to _detach_ the _state subscription_ from the current component:
154+
By default, _state subscriptions_ are bound to the component where they are added (if the store is inside a component's `setup()`). Meaning, they will be automatically removed when the component is unmounted. If you want to keep them after the component is unmounted, pass `{ detached: true }` as the second argument to _detach_ the _state subscription_ from the current component:
155155

156156
```js
157157
export default {
158158
setup() {
159159
const someStore = useSomeStore()
160160

161161
// this subscription will be kept after the component is unmounted
162-
someStore.$subscribe(callback, true)
162+
someStore.$subscribe(callback, { detached: true })
163163

164164
// ...
165165
},

packages/pinia/__tests__/subscriptions.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('Subscriptions', () => {
3737
const patch = { user: 'Cleiton' }
3838
store.$patch(patch)
3939

40+
expect(spy).toHaveBeenCalledTimes(1)
4041
expect(spy).toHaveBeenCalledWith(
4142
expect.objectContaining({
4243
payload: patch,
@@ -46,6 +47,116 @@ describe('Subscriptions', () => {
4647
store.$state
4748
)
4849
})
50+
const flushOptions = ['post', 'pre', 'sync'] as const
51+
52+
flushOptions.forEach((flush) => {
53+
it('calls once inside components with flush ' + flush, async () => {
54+
const pinia = createPinia()
55+
setActivePinia(pinia)
56+
const spy1 = jest.fn()
57+
58+
mount(
59+
{
60+
setup() {
61+
const s1 = useStore()
62+
s1.$subscribe(spy1, { flush })
63+
},
64+
template: `<p/>`,
65+
},
66+
{ global: { plugins: [pinia] } }
67+
)
68+
69+
const s1 = useStore()
70+
71+
expect(spy1).toHaveBeenCalledTimes(0)
72+
73+
s1.user = 'Edu'
74+
await nextTick()
75+
await nextTick()
76+
expect(spy1).toHaveBeenCalledTimes(1)
77+
78+
s1.$patch({ user: 'a' })
79+
await nextTick()
80+
await nextTick()
81+
expect(spy1).toHaveBeenCalledTimes(2)
82+
83+
s1.$patch((state) => {
84+
state.user = 'other'
85+
})
86+
await nextTick()
87+
await nextTick()
88+
expect(spy1).toHaveBeenCalledTimes(3)
89+
})
90+
})
91+
92+
it('works with multiple different flush', async () => {
93+
const spyPre = jest.fn()
94+
const spyPost = jest.fn()
95+
const spySync = jest.fn()
96+
97+
const s1 = useStore()
98+
s1.$subscribe(spyPre, { flush: 'pre' })
99+
s1.$subscribe(spyPost, { flush: 'post' })
100+
s1.$subscribe(spySync, { flush: 'sync' })
101+
102+
expect(spyPre).toHaveBeenCalledTimes(0)
103+
expect(spyPost).toHaveBeenCalledTimes(0)
104+
expect(spySync).toHaveBeenCalledTimes(0)
105+
106+
s1.user = 'Edu'
107+
expect(spyPre).toHaveBeenCalledTimes(0)
108+
expect(spyPost).toHaveBeenCalledTimes(0)
109+
expect(spySync).toHaveBeenCalledTimes(1)
110+
await nextTick()
111+
expect(spyPre).toHaveBeenCalledTimes(1)
112+
expect(spyPost).toHaveBeenCalledTimes(1)
113+
expect(spySync).toHaveBeenCalledTimes(1)
114+
115+
s1.$patch({ user: 'a' })
116+
// patch still triggers all subscriptions immediately
117+
expect(spyPre).toHaveBeenCalledTimes(2)
118+
expect(spyPost).toHaveBeenCalledTimes(2)
119+
expect(spySync).toHaveBeenCalledTimes(2)
120+
await nextTick()
121+
expect(spyPre).toHaveBeenCalledTimes(2)
122+
expect(spyPost).toHaveBeenCalledTimes(2)
123+
expect(spySync).toHaveBeenCalledTimes(2)
124+
125+
s1.$patch((state) => {
126+
state.user = 'other'
127+
})
128+
expect(spyPre).toHaveBeenCalledTimes(3)
129+
expect(spyPost).toHaveBeenCalledTimes(3)
130+
expect(spySync).toHaveBeenCalledTimes(3)
131+
await nextTick()
132+
expect(spyPre).toHaveBeenCalledTimes(3)
133+
expect(spyPost).toHaveBeenCalledTimes(3)
134+
expect(spySync).toHaveBeenCalledTimes(3)
135+
})
136+
137+
it('works with multiple different flush and multiple state changes', async () => {
138+
const spyPre = jest.fn()
139+
const spyPost = jest.fn()
140+
const spySync = jest.fn()
141+
142+
const s1 = useStore()
143+
s1.$subscribe(spyPre, { flush: 'pre' })
144+
s1.$subscribe(spyPost, { flush: 'post' })
145+
s1.$subscribe(spySync, { flush: 'sync' })
146+
147+
s1.user = 'Edu'
148+
expect(spyPre).toHaveBeenCalledTimes(0)
149+
expect(spyPost).toHaveBeenCalledTimes(0)
150+
expect(spySync).toHaveBeenCalledTimes(1)
151+
s1.$patch({ user: 'a' })
152+
expect(spyPre).toHaveBeenCalledTimes(1)
153+
expect(spyPost).toHaveBeenCalledTimes(1)
154+
expect(spySync).toHaveBeenCalledTimes(2)
155+
await nextTick()
156+
expect(spyPre).toHaveBeenCalledTimes(1)
157+
expect(spyPost).toHaveBeenCalledTimes(1)
158+
expect(spySync).toHaveBeenCalledTimes(2)
159+
})
49160

50161
it('unsubscribes callback when unsubscribe is called', () => {
51162
const spy = jest.fn()

packages/pinia/src/store.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ref,
2121
set,
2222
del,
23+
nextTick,
2324
isVue2,
2425
} from 'vue-demi'
2526
import {
@@ -229,6 +230,7 @@ function createSetupStore<
229230

230231
// internal state
231232
let isListening: boolean // set to true at the end
233+
let isSyncListening: boolean // set to true at the end
232234
let subscriptions: SubscriptionCallback<S>[] = markRaw([])
233235
let actionSubscriptions: StoreOnActionListener<Id, S, G, A>[] = markRaw([])
234236
let debuggerEvents: DebuggerEvent[] | DebuggerEvent
@@ -255,7 +257,7 @@ function createSetupStore<
255257
| ((state: UnwrapRef<S>) => void)
256258
): void {
257259
let subscriptionMutation: SubscriptionCallbackMutation<S>
258-
isListening = false
260+
isListening = isSyncListening = false
259261
// reset the debugger events since patches are sync
260262
/* istanbul ignore else */
261263
if (__DEV__) {
@@ -277,7 +279,10 @@ function createSetupStore<
277279
events: debuggerEvents as DebuggerEvent[],
278280
}
279281
}
280-
isListening = true
282+
nextTick().then(() => {
283+
isListening = true
284+
})
285+
isSyncListening = true
281286
// because we paused the watcher, we need to manually call the subscriptions
282287
triggerSubscriptions(
283288
subscriptions,
@@ -384,7 +389,7 @@ function createSetupStore<
384389
watch(
385390
() => pinia.state.value[$id] as UnwrapRef<S>,
386391
(state) => {
387-
if (isListening) {
392+
if (options.flush === 'sync' ? isSyncListening : isListening) {
388393
callback(
389394
{
390395
storeId: $id,
@@ -575,8 +580,12 @@ function createSetupStore<
575580

576581
// avoid devtools logging this as a mutation
577582
isListening = false
583+
isSyncListening = false
578584
pinia.state.value[$id] = toRef(newStore._hmrPayload, 'hotState')
579-
isListening = true
585+
isSyncListening = true
586+
nextTick().then(() => {
587+
isListening = true
588+
})
580589

581590
for (const actionName in newStore._hmrPayload.actions) {
582591
const action: _Method = newStore[actionName]
@@ -702,6 +711,7 @@ function createSetupStore<
702711
}
703712

704713
isListening = true
714+
isSyncListening = true
705715
return store
706716
}
707717

packages/pinia/src/types.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -349,15 +349,13 @@ export interface _StoreWithState<
349349
$reset(): void
350350

351351
/**
352-
* Setups a callback to be called whenever the state changes. It also returns
353-
* a function to remove the callback. Note than when calling
354-
* `store.$subscribe()` inside of a component, it will be automatically
355-
* cleanup up when the component gets unmounted unless `detached` is set to
356-
* true.
352+
* Setups a callback to be called whenever the state changes. It also returns a function to remove the callback. Note
353+
* than when calling `store.$subscribe()` inside of a component, it will be automatically cleanup up when the
354+
* component gets unmounted unless `detached` is set to true.
357355
*
358356
* @param callback - callback passed to the watcher
359-
* @param options - `watch` options + `detached` to detach the subscription
360-
* from the context (usually a component) this is called from
357+
* @param options - `watch` options + `detached` to detach the subscription from the context (usually a component)
358+
* this is called from. Note that the `flush` option does not affect calls to `store.$patch()`.
361359
* @returns function that removes the watcher
362360
*/
363361
$subscribe(

0 commit comments

Comments
 (0)