diff --git a/packages/pinia/__tests__/store.patch.spec.ts b/packages/pinia/__tests__/store.patch.spec.ts index 5a94c15ac2..58fb4cb4c4 100644 --- a/packages/pinia/__tests__/store.patch.spec.ts +++ b/packages/pinia/__tests__/store.patch.spec.ts @@ -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()) @@ -215,4 +218,138 @@ 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 markedRaw = ref({ + marked: markRaw({ count: 0 }), + }) + const nestedCounter = shallowRef({ + nested: { count: 0 }, + simple: 1, + }) + + return { + markedRaw, + counter, + 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.counter.count, watcherSpy, { flush: 'sync' }) + + watcherSpy.mockClear() + store.$patch((state) => { + state.counter = { count: state.counter.count + 1 } + }) + + expect(watcherSpy).toHaveBeenCalledTimes(1) + }) + + it('works with direct assignment (baseline test)', async () => { + const store = useShallowRefStore() + const watcherSpy = vi.fn() + + watch(() => store.counter.count, watcherSpy, { flush: 'sync' }) + + watcherSpy.mockClear() + store.counter = { 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.nestedCounter.simple, watcherSpy2, { flush: 'sync' }) + + watcherSpy1.mockClear() + watcherSpy2.mockClear() + + store.$patch({ + counter: { count: 10 }, + nestedCounter: { nested: { count: 0 }, simple: 20 }, + }) + + expect(watcherSpy1).toHaveBeenCalledTimes(1) + expect(watcherSpy2).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/pinia/src/store.ts b/packages/pinia/src/store.ts index 7ec0e17aeb..43c4fcba43 100644 --- a/packages/pinia/src/store.ts +++ b/packages/pinia/src/store.ts @@ -5,33 +5,34 @@ import { hasInjectionContext, getCurrentInstance, reactive, - DebuggerEvent, - WatchOptions, - UnwrapRef, markRaw, isRef, isReactive, + isShallow, effectScope, - EffectScope, - ComputedRef, toRaw, toRef, toRefs, - Ref, ref, nextTick, + triggerRef, + type DebuggerEvent, + type WatchOptions, + type UnwrapRef, + type EffectScope, + type ComputedRef, + type ShallowRef, + type Ref, } from 'vue' -import { +import type { + _DeepPartial, StateTree, SubscriptionCallback, - _DeepPartial, - isPlainObject, Store, _Method, DefineStoreOptions, StoreDefinition, _GettersTree, - MutationType, StoreOnActionListener, _ActionsTree, SubscriptionCallbackMutation, @@ -46,7 +47,13 @@ import { _ExtractStateFromSetupStore, _StoreWithState, } from './types' -import { setActivePinia, piniaSymbol, Pinia, activePinia } from './rootStore' +import { isPlainObject, MutationType } from './types' +import { + setActivePinia, + piniaSymbol, + type Pinia, + activePinia, +} from './rootStore' import { IS_CLIENT } from './env' import { patchObject } from './hmr' import { addSubscription, triggerSubscriptions, noop } from './subscriptions' @@ -86,11 +93,15 @@ function mergeReactiveObjects< patchToApply.forEach(target.add, target) } + // the raw version lets us see shallow refs + const rawTarget = toRaw(target) + // no need to go through symbols because they cannot be serialized anyway for (const key in patchToApply) { if (!patchToApply.hasOwnProperty(key)) continue - const subPatch = patchToApply[key] - const targetValue = target[key] + var subPatch = patchToApply[key] + var targetValue = target[key] + if ( isPlainObject(targetValue) && isPlainObject(subPatch) && @@ -106,6 +117,11 @@ function mergeReactiveObjects< // @ts-expect-error: subPatch is a valid value target[key] = subPatch } + + // enables $patching shallow refs + if (isShallow(rawTarget[key])) { + triggerRef(rawTarget[key] as ShallowRef) + } } return target @@ -284,6 +300,7 @@ function createSetupStore< // avoid triggering too many listeners // https://github.com/vuejs/pinia/issues/1129 let activeListener: Symbol | undefined + function $patch(stateMutation: (state: UnwrapRef) => void): void function $patch(partialState: _DeepPartial>): void function $patch( @@ -307,6 +324,7 @@ function createSetupStore< } } else { mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator) + subscriptionMutation = { type: MutationType.patchObject, payload: partialStateOrMutator,