fix(stage-ui): useVersionedLocalStorage did not return the patched value back

This commit is contained in:
Neko Ayaka
2026-05-21 02:53:56 +08:00
parent b2dfe03d11
commit 018ad05fd7
2 changed files with 80 additions and 3 deletions
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useVersionedLocalStorage } from '.'
class MemoryStorage {
#items = new Map<string, string>()
getItem(key: string) {
return this.#items.get(key) ?? null
}
removeItem(key: string) {
this.#items.delete(key)
}
setItem(key: string, value: string) {
this.#items.set(key, value)
}
}
const storage = new MemoryStorage()
afterEach(() => {
storage.removeItem('settings/live2d/auto-blink-enabled')
})
describe('useVersionedLocalStorage', () => {
/**
* @example
* expect(JSON.parse(localStorage.getItem('settings/live2d/auto-blink-enabled')!)).toEqual({ version: '2.0.0', data: false })
*/
it('persists returned ref changes into the versioned localStorage wrapper', async () => {
const value = useVersionedLocalStorage('settings/live2d/auto-blink-enabled', true, {
defaultVersion: '2.0.0',
storage,
})
// ROOT CAUSE:
//
// Versioned storage exposed an unwrapped `data` ref but only synchronized
// storage -> data. UI changes updated the returned ref, while localStorage
// stayed at the old `{ version, data }` wrapper value.
//
// We fixed this by syncing data -> storage without echoing storage reads
// back into writes.
value.value = false
await nextTick()
expect(JSON.parse(storage.getItem('settings/live2d/auto-blink-enabled')!)).toEqual({
version: '2.0.0',
data: false,
})
})
})
@@ -1,12 +1,13 @@
import type { UseStorageOptions } from '@vueuse/core'
import type { StorageLike, UseStorageOptions } from '@vueuse/core'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { defaultWindow, useStorage } from '@vueuse/core'
import { ref, toValue, watch } from 'vue'
export interface Versioned<T> { version?: string, data?: T }
export interface UseVersionedStorageOptions<T> {
defaultVersion?: string
storage?: StorageLike
satisfiesVersionBy?: (beforeVersion: string, afterVersion: string) => boolean
onVersionMismatch?: (value: Versioned<T>) => OnVersionMismatchActions<T>
}
@@ -22,7 +23,18 @@ export function useVersionedLocalStorage<T>(
): Ref<T, T> {
const defaultVersion = options?.defaultVersion || '1.0.0'
const data = ref(toValue(initialValue)) as Ref<T, T>
const rawValue = useLocalStorage<Versioned<T>>(key, { version: defaultVersion, data: toValue(initialValue) }, options as unknown as UseStorageOptions<Versioned<T>>)
const rawValue = useStorage<Versioned<T>>(
key,
{ version: defaultVersion, data: toValue(initialValue) },
options?.storage ?? defaultWindow?.localStorage,
options as unknown as UseStorageOptions<Versioned<T>>,
)
const syncDataToStorage = watch(data, (value) => {
rawValue.value = { version: defaultVersion, data: value }
}, {
deep: true,
})
watch(rawValue, (value) => {
try {
@@ -32,28 +44,38 @@ export function useVersionedLocalStorage<T>(
const action = options.onVersionMismatch(rawValue.value)
if (action.action === 'reset') {
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
syncDataToStorage.pause()
data.value = toValue(initialValue)
syncDataToStorage.resume()
}
}
else {
console.warn(`version ${rawValue.value.version} doesn't satisfy the version ${defaultVersion} for key ${key}, will reset the value to default value ${toValue(initialValue)}`)
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
syncDataToStorage.pause()
data.value = toValue(initialValue)
syncDataToStorage.resume()
}
}
syncDataToStorage.pause()
data.value = rawValue.value.data!
syncDataToStorage.resume()
return
}
console.warn(`property key 'version' wasn't found in the value of key ${key} as ${value}, will keep the current ${toValue(initialValue)}`)
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
syncDataToStorage.pause()
data.value = toValue(initialValue)
syncDataToStorage.resume()
}
catch (err) {
console.warn(`failed to un-marshal Local Storage value, possibly due to incompatible or corrupted for key ${key} value ${value}, falling back to default value ${toValue(initialValue)}`, err)
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
syncDataToStorage.pause()
data.value = toValue(initialValue)
syncDataToStorage.resume()
}
}, {
immediate: true,