Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
141 changes: 139 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,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)
})
})
})
44 changes: 31 additions & 13 deletions packages/pinia/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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]
Comment on lines +102 to +103
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use const instead of var for loop variables.

These variables are not reassigned within the loop body, so const is more appropriate and prevents accidental reassignment. Using var is a code smell in modern JavaScript and provides function scope rather than block scope.

Apply this diff:

-    var subPatch = patchToApply[key]
-    var targetValue = target[key]
+    const subPatch = patchToApply[key]
+    const targetValue = target[key]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var subPatch = patchToApply[key]
var targetValue = target[key]
const subPatch = patchToApply[key]
const targetValue = target[key]
🤖 Prompt for AI Agents
In packages/pinia/src/store.ts around lines 102 to 103, the loop variables are
declared with var (var subPatch = patchToApply[key]; var targetValue =
target[key]) but they are never reassigned; replace var with const for both
declarations to use block-scoped, read-only bindings and avoid accidental
reassignment. Update the two declarations to use const and run tests/lint to
ensure no other references rely on var semantics.


if (
isPlainObject(targetValue) &&
isPlainObject(subPatch) &&
Expand All @@ -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
Expand Down Expand Up @@ -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<S>) => void): void
function $patch(partialState: _DeepPartial<UnwrapRef<S>>): void
function $patch(
Expand All @@ -307,6 +324,7 @@ function createSetupStore<
}
} else {
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)

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