Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 143 additions & 2 deletions packages/pinia/__tests__/store.patch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, it, expect } from 'vitest'
import { reactive, ref } from 'vue'
import { describe, it, expect, vi } from 'vitest'
import { reactive, ref, shallowRef, markRaw, watch } from 'vue'
import { createPinia, defineStore, Pinia, setActivePinia } from '../src'
import { mockWarn } from './vitest-mock-warn'

describe('store.$patch', () => {
mockWarn()

const useStore = () => {
// create a new store
setActivePinia(createPinia())
Expand Down Expand Up @@ -215,4 +218,142 @@ describe('store.$patch', () => {
expect(store.item).toEqual({ a: 1, b: 1 })
})
})

describe('shallowRef reactivity', () => {
const useShallowRefStore = () => {
setActivePinia(createPinia())
return defineStore('shallowRef', () => {
const counter = shallowRef({ count: 0 })
const counter2 = shallowRef({ count: 0 })
const counter3 = shallowRef({ count: 0 })
const markedRaw = ref({
marked: markRaw({ count: 0 }),
})
const nestedCounter = shallowRef({
nested: { count: 0 },
simple: 1,
})

return {
markedRaw,
counter,
counter2,
counter3,
nestedCounter,
}
})()
}

it('does not trigger reactivity when patching marked raw', async () => {
const store = useShallowRefStore()
const markedSpy = vi.fn()
const nestedSpy = vi.fn()
watch(() => store.markedRaw.marked, markedSpy, {
flush: 'sync',
deep: true,
})
watch(() => store.markedRaw.marked.count, nestedSpy, { flush: 'sync' })
store.$patch({ markedRaw: { marked: { count: 1 } } })
expect(nestedSpy).toHaveBeenCalledTimes(0)
expect(markedSpy).toHaveBeenCalledTimes(0)
})

it('triggers reactivity when patching shallowRef with object syntax', async () => {
const store = useShallowRefStore()
const watcherSpy = vi.fn()

watch(() => store.counter.count, watcherSpy, { flush: 'sync' })

watcherSpy.mockClear()
store.$patch({ counter: { count: 1 } })

expect(watcherSpy).toHaveBeenCalledTimes(1)
})

it('triggers reactivity when patching nested properties in shallowRef', async () => {
const store = useShallowRefStore()
const watcherSpy = vi.fn()

watch(() => store.nestedCounter.nested.count, watcherSpy, {
flush: 'sync',
})

watcherSpy.mockClear()
store.$patch({
nestedCounter: {
nested: { count: 5 },
simple: 2,
},
})

expect(watcherSpy).toHaveBeenCalledTimes(1)
})

it('works with function syntax (baseline test)', async () => {
const store = useShallowRefStore()
const watcherSpy = vi.fn()

watch(() => store.counter2.count, watcherSpy, { flush: 'sync' })

watcherSpy.mockClear()
store.$patch((state) => {
state.counter2 = { count: state.counter2.count + 1 }
})

expect(watcherSpy).toHaveBeenCalledTimes(1)
})

it('works with direct assignment (baseline test)', async () => {
const store = useShallowRefStore()
const watcherSpy = vi.fn()

watch(() => store.counter3.count, watcherSpy, { flush: 'sync' })

watcherSpy.mockClear()
store.counter3 = { count: 3 }

expect(watcherSpy).toHaveBeenCalledTimes(1)
})

it('handles partial updates correctly', async () => {
const store = useShallowRefStore()

// Set initial state with multiple properties
store.nestedCounter = {
nested: { count: 10 },
simple: 20,
}

// Patch only one property
store.$patch({
nestedCounter: {
nested: { count: 15 },
// Note: simple is not included, should remain unchanged
},
})

expect(store.nestedCounter.nested.count).toBe(15)
expect(store.nestedCounter.simple).toBe(20) // Should remain unchanged
})

it('works with multiple shallowRefs in single patch', async () => {
const store = useShallowRefStore()
const watcherSpy1 = vi.fn()
const watcherSpy2 = vi.fn()

watch(() => store.counter.count, watcherSpy1, { flush: 'sync' })
watch(() => store.counter2.count, watcherSpy2, { flush: 'sync' })

watcherSpy1.mockClear()
watcherSpy2.mockClear()

store.$patch({
counter: { count: 10 },
counter2: { count: 20 },
})

expect(watcherSpy1).toHaveBeenCalledTimes(1)
expect(watcherSpy2).toHaveBeenCalledTimes(1)
})
})
})
36 changes: 34 additions & 2 deletions packages/pinia/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
markRaw,
isRef,
isReactive,
isShallow,
effectScope,
EffectScope,
ComputedRef,
Expand All @@ -20,6 +21,7 @@ import {
Ref,
ref,
nextTick,
triggerRef,
} from 'vue'
import {
StateTree,
Expand Down Expand Up @@ -88,13 +90,14 @@ function mergeReactiveObjects<

// no need to go through symbols because they cannot be serialized anyway
for (const key in patchToApply) {
if (!patchToApply.hasOwnProperty(key)) continue
if (!Object.prototype.hasOwnProperty.call(patchToApply, key)) continue
const subPatch = patchToApply[key]
const targetValue = target[key]

if (
isPlainObject(targetValue) &&
isPlainObject(subPatch) &&
target.hasOwnProperty(key) &&
Object.prototype.hasOwnProperty.call(target, key) &&
!isRef(subPatch) &&
!isReactive(subPatch)
) {
Expand Down Expand Up @@ -146,6 +149,15 @@ function isComputed(o: any): o is ComputedRef {
return !!(isRef(o) && (o as any).effect)
}

/**
* Checks if a value is a shallowRef
* @param value - value to check
* @returns true if the value is a shallowRef
*/
function isShallowRef(value: any): value is Ref {
return isRef(value) && isShallow(value)
}

function createOptionsStore<
Id extends string,
S extends StateTree,
Expand Down Expand Up @@ -284,6 +296,7 @@ function createSetupStore<
// avoid triggering too many listeners
// https://github.com/vuejs/pinia/issues/1129
let activeListener: Symbol | undefined

function $patch(stateMutation: (state: UnwrapRef<S>) => void): void
function $patch(partialState: _DeepPartial<UnwrapRef<S>>): void
function $patch(
Expand All @@ -307,6 +320,25 @@ function createSetupStore<
}
} else {
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)

// Handle shallowRef reactivity: inspect raw store to avoid ref unwrapping
{
const rawStore = toRaw(store) as Record<string, unknown>
const shallowRefsToTrigger: Ref[] = []
for (const key in partialStateOrMutator) {
if (!Object.prototype.hasOwnProperty.call(partialStateOrMutator, key))
continue
const prop = (rawStore as any)[key]
if (
isShallowRef(prop) &&
isPlainObject((partialStateOrMutator as any)[key])
) {
shallowRefsToTrigger.push(prop)
}
}
shallowRefsToTrigger.forEach(triggerRef)
}

subscriptionMutation = {
type: MutationType.patchObject,
payload: partialStateOrMutator,
Expand Down
Loading