From 018ad05fd719289903f043300e240b39a81a7dc2 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 21 May 2026 02:53:56 +0800 Subject: [PATCH] fix(stage-ui): useVersionedLocalStorage did not return the patched value back --- .../use-versioned-local-storage/index.test.ts | 55 +++++++++++++++++++ .../use-versioned-local-storage/index.ts | 28 +++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/stage-shared/src/composables/use-versioned-local-storage/index.test.ts diff --git a/packages/stage-shared/src/composables/use-versioned-local-storage/index.test.ts b/packages/stage-shared/src/composables/use-versioned-local-storage/index.test.ts new file mode 100644 index 000000000..c5eaf192a --- /dev/null +++ b/packages/stage-shared/src/composables/use-versioned-local-storage/index.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' + +import { useVersionedLocalStorage } from '.' + +class MemoryStorage { + #items = new Map() + + 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, + }) + }) +}) diff --git a/packages/stage-shared/src/composables/use-versioned-local-storage/index.ts b/packages/stage-shared/src/composables/use-versioned-local-storage/index.ts index 9c98e219b..0a997cd05 100644 --- a/packages/stage-shared/src/composables/use-versioned-local-storage/index.ts +++ b/packages/stage-shared/src/composables/use-versioned-local-storage/index.ts @@ -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 { version?: string, data?: T } export interface UseVersionedStorageOptions { defaultVersion?: string + storage?: StorageLike satisfiesVersionBy?: (beforeVersion: string, afterVersion: string) => boolean onVersionMismatch?: (value: Versioned) => OnVersionMismatchActions } @@ -22,7 +23,18 @@ export function useVersionedLocalStorage( ): Ref { const defaultVersion = options?.defaultVersion || '1.0.0' const data = ref(toValue(initialValue)) as Ref - const rawValue = useLocalStorage>(key, { version: defaultVersion, data: toValue(initialValue) }, options as unknown as UseStorageOptions>) + const rawValue = useStorage>( + key, + { version: defaultVersion, data: toValue(initialValue) }, + options?.storage ?? defaultWindow?.localStorage, + options as unknown as UseStorageOptions>, + ) + + const syncDataToStorage = watch(data, (value) => { + rawValue.value = { version: defaultVersion, data: value } + }, { + deep: true, + }) watch(rawValue, (value) => { try { @@ -32,28 +44,38 @@ export function useVersionedLocalStorage( 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,