Skip to content

Commit 3cf2466

Browse files
committed
fix: trigger reactivity for shallowRef when using object syntax
- Add triggerRef import from Vue - Implement isShallowRef utility function to detect shallowRef using __v_isShallow - Modify method to detect shallowRef targets and trigger reactivity after patching - Add comprehensive tests for shallowRef reactivity scenarios - Ensure compatibility with existing patch behavior close #2861
1 parent 57bec95 commit 3cf2466

File tree

2 files changed

+226
-2
lines changed

2 files changed

+226
-2
lines changed

packages/pinia/__tests__/store.patch.spec.ts

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect } from 'vitest'
2-
import { reactive, ref } from 'vue'
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { reactive, ref, shallowRef, computed, nextTick, watchEffect } from 'vue'
33
import { createPinia, defineStore, Pinia, setActivePinia } from '../src'
44

55
describe('store.$patch', () => {
@@ -215,4 +215,188 @@ describe('store.$patch', () => {
215215
expect(store.item).toEqual({ a: 1, b: 1 })
216216
})
217217
})
218+
219+
describe('shallowRef reactivity', () => {
220+
const useShallowRefStore = () => {
221+
setActivePinia(createPinia())
222+
return defineStore('shallowRef', () => {
223+
const counter = shallowRef({ count: 0 })
224+
const counter2 = shallowRef({ count: 0 })
225+
const counter3 = shallowRef({ count: 0 })
226+
const nestedCounter = shallowRef({
227+
nested: { count: 0 },
228+
simple: 1,
229+
})
230+
231+
return { counter, counter2, counter3, nestedCounter }
232+
})()
233+
}
234+
235+
it('triggers reactivity when patching shallowRef with object syntax', async () => {
236+
const store = useShallowRefStore()
237+
const watcherSpy = vi.fn()
238+
239+
// Create a computed that depends on the shallowRef
240+
const doubleCount = computed(() => store.counter.count * 2)
241+
242+
// Watch the computed to verify reactivity
243+
const stopWatcher = watchEffect(() => {
244+
watcherSpy(doubleCount.value)
245+
})
246+
247+
expect(watcherSpy).toHaveBeenCalledWith(0)
248+
watcherSpy.mockClear()
249+
250+
// Patch using object syntax - this should trigger reactivity
251+
store.$patch({ counter: { count: 1 } })
252+
253+
await nextTick()
254+
255+
expect(store.counter.count).toBe(1)
256+
expect(doubleCount.value).toBe(2)
257+
expect(watcherSpy).toHaveBeenCalledWith(2)
258+
259+
stopWatcher()
260+
})
261+
262+
it('triggers reactivity when patching nested properties in shallowRef', async () => {
263+
const store = useShallowRefStore()
264+
const watcherSpy = vi.fn()
265+
266+
const nestedCount = computed(() => store.nestedCounter.nested.count)
267+
268+
const stopWatcher = watchEffect(() => {
269+
watcherSpy(nestedCount.value)
270+
})
271+
272+
expect(watcherSpy).toHaveBeenCalledWith(0)
273+
watcherSpy.mockClear()
274+
275+
// Patch nested properties
276+
store.$patch({
277+
nestedCounter: {
278+
nested: { count: 5 },
279+
simple: 2,
280+
},
281+
})
282+
283+
await nextTick()
284+
285+
expect(store.nestedCounter.nested.count).toBe(5)
286+
expect(store.nestedCounter.simple).toBe(2)
287+
expect(nestedCount.value).toBe(5)
288+
expect(watcherSpy).toHaveBeenCalledWith(5)
289+
290+
stopWatcher()
291+
})
292+
293+
it('works with function syntax (baseline test)', async () => {
294+
const store = useShallowRefStore()
295+
const watcherSpy = vi.fn()
296+
297+
const doubleCount = computed(() => store.counter2.count * 2)
298+
299+
const stopWatcher = watchEffect(() => {
300+
watcherSpy(doubleCount.value)
301+
})
302+
303+
expect(watcherSpy).toHaveBeenCalledWith(0)
304+
watcherSpy.mockClear()
305+
306+
// Function syntax should work (this was already working)
307+
store.$patch((state) => {
308+
state.counter2 = { count: state.counter2.count + 1 }
309+
})
310+
311+
await nextTick()
312+
313+
expect(store.counter2.count).toBe(1)
314+
expect(doubleCount.value).toBe(2)
315+
expect(watcherSpy).toHaveBeenCalledWith(2)
316+
317+
stopWatcher()
318+
})
319+
320+
it('works with direct assignment (baseline test)', async () => {
321+
const store = useShallowRefStore()
322+
const watcherSpy = vi.fn()
323+
324+
const doubleCount = computed(() => store.counter3.count * 2)
325+
326+
const stopWatcher = watchEffect(() => {
327+
watcherSpy(doubleCount.value)
328+
})
329+
330+
expect(watcherSpy).toHaveBeenCalledWith(0)
331+
watcherSpy.mockClear()
332+
333+
// Direct assignment should work (this was already working)
334+
store.counter3 = { count: 3 }
335+
336+
await nextTick()
337+
338+
expect(store.counter3.count).toBe(3)
339+
expect(doubleCount.value).toBe(6)
340+
expect(watcherSpy).toHaveBeenCalledWith(6)
341+
342+
stopWatcher()
343+
})
344+
345+
it('handles partial updates correctly', async () => {
346+
const store = useShallowRefStore()
347+
348+
// Set initial state with multiple properties
349+
store.nestedCounter = {
350+
nested: { count: 10 },
351+
simple: 20,
352+
}
353+
354+
// Patch only one property
355+
store.$patch({
356+
nestedCounter: {
357+
nested: { count: 15 },
358+
// Note: simple is not included, should remain unchanged
359+
},
360+
})
361+
362+
expect(store.nestedCounter.nested.count).toBe(15)
363+
expect(store.nestedCounter.simple).toBe(20) // Should remain unchanged
364+
})
365+
366+
it('works with multiple shallowRefs in single patch', async () => {
367+
const store = useShallowRefStore()
368+
const watcherSpy1 = vi.fn()
369+
const watcherSpy2 = vi.fn()
370+
371+
const count1 = computed(() => store.counter.count)
372+
const count2 = computed(() => store.counter2.count)
373+
374+
const stopWatcher1 = watchEffect(() => {
375+
watcherSpy1(count1.value)
376+
})
377+
378+
const stopWatcher2 = watchEffect(() => {
379+
watcherSpy2(count2.value)
380+
})
381+
382+
watcherSpy1.mockClear()
383+
watcherSpy2.mockClear()
384+
385+
// Patch multiple shallowRefs at once
386+
store.$patch({
387+
counter: { count: 10 },
388+
counter2: { count: 20 },
389+
})
390+
391+
await nextTick()
392+
393+
expect(store.counter.count).toBe(10)
394+
expect(store.counter2.count).toBe(20)
395+
expect(watcherSpy1).toHaveBeenCalledWith(10)
396+
expect(watcherSpy2).toHaveBeenCalledWith(20)
397+
398+
stopWatcher1()
399+
stopWatcher2()
400+
})
401+
})
218402
})

packages/pinia/src/store.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
Ref,
2121
ref,
2222
nextTick,
23+
triggerRef,
2324
} from 'vue'
2425
import {
2526
StateTree,
@@ -91,6 +92,7 @@ function mergeReactiveObjects<
9192
if (!patchToApply.hasOwnProperty(key)) continue
9293
const subPatch = patchToApply[key]
9394
const targetValue = target[key]
95+
9496
if (
9597
isPlainObject(targetValue) &&
9698
isPlainObject(subPatch) &&
@@ -146,6 +148,15 @@ function isComputed(o: any): o is ComputedRef {
146148
return !!(isRef(o) && (o as any).effect)
147149
}
148150

151+
/**
152+
* Checks if a value is a shallowRef
153+
* @param value - value to check
154+
* @returns true if the value is a shallowRef
155+
*/
156+
function isShallowRef(value: any): value is Ref {
157+
return isRef(value) && !!(value as any).__v_isShallow
158+
}
159+
149160
function createOptionsStore<
150161
Id extends string,
151162
S extends StateTree,
@@ -284,6 +295,10 @@ function createSetupStore<
284295
// avoid triggering too many listeners
285296
// https://github.com/vuejs/pinia/issues/1129
286297
let activeListener: Symbol | undefined
298+
299+
// Store reference for shallowRef handling - will be set after setupStore creation
300+
let setupStoreRef: any = null
301+
287302
function $patch(stateMutation: (state: UnwrapRef<S>) => void): void
288303
function $patch(partialState: _DeepPartial<UnwrapRef<S>>): void
289304
function $patch(
@@ -307,6 +322,28 @@ function createSetupStore<
307322
}
308323
} else {
309324
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)
325+
326+
// Handle shallowRef reactivity: check if any patched properties are shallowRefs
327+
// and trigger their reactivity manually
328+
if (setupStoreRef) {
329+
const shallowRefsToTrigger: any[] = []
330+
for (const key in partialStateOrMutator) {
331+
if (partialStateOrMutator.hasOwnProperty(key)) {
332+
// Check if the property in the setupStore is a shallowRef
333+
const setupStoreProperty = setupStoreRef[key]
334+
if (
335+
isShallowRef(setupStoreProperty) &&
336+
isPlainObject(partialStateOrMutator[key])
337+
) {
338+
shallowRefsToTrigger.push(setupStoreProperty)
339+
}
340+
}
341+
}
342+
343+
// Trigger reactivity for all shallowRefs that were patched
344+
shallowRefsToTrigger.forEach(triggerRef)
345+
}
346+
310347
subscriptionMutation = {
311348
type: MutationType.patchObject,
312349
payload: partialStateOrMutator,
@@ -494,6 +531,9 @@ function createSetupStore<
494531
pinia._e.run(() => (scope = effectScope()).run(() => setup({ action }))!)
495532
)!
496533

534+
// Set setupStore reference for shallowRef handling in $patch
535+
setupStoreRef = setupStore
536+
497537
// overwrite existing actions to support $onAction
498538
for (const key in setupStore) {
499539
const prop = setupStore[key]

0 commit comments

Comments
 (0)