Merge pull request 'Fix legacy settings migration' (#3562) from fix/config-migration-3559 into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma-fe/pulls/3562
This commit is contained in:
HJ 2026-08-16 13:26:40 +00:00
commit 99c7d06758
3 changed files with 295 additions and 74 deletions

View file

@ -0,0 +1 @@
Fix legacy settings migration being discarded after it ran.

View file

@ -687,83 +687,13 @@ export const useSyncConfigStore = defineStore('sync_config', {
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
prefsStorage: cloneDeep(defaultState.prefsStorage),
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const present = oldValue !== undefined
const migrated = migratedEntries.has(key)
const different = !isEqual(oldValue, defaultValue)
if (present && !migrated && different) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPost',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
useSyncConfigStore().setSimplePrefAndSave({
path: 'underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
useSyncConfigStore().setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: `simple.${key}`, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
@ -794,14 +724,99 @@ export const useSyncConfigStore = defineStore('sync_config', {
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
if (!Array.isArray(this.prefsStorage._journal)) {
this.prefsStorage._journal = []
}
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const migrated = migratedEntries.has(key)
const preferencePath =
key === 'theme3hacks' ? 'simple.underlay' : `simple.${key}`
const present = oldValue !== undefined
const different = !isEqual(oldValue, defaultValue)
const preferenceHandled =
get(this.prefsStorage, preferencePath) !== undefined ||
this.prefsStorage._journal.some(
(entry) =>
entry?.path === preferencePath ||
entry?.path?.startsWith?.(`${preferencePath}.`),
)
if (present && different && !preferenceHandled) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
if (!migrated) {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPosts',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
}
this.setPreference({
path: 'simple.underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
this.setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: preferencePath, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
this.dirty = dirty || needUpload
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
this.pushSyncConfig()
},
pushSyncConfig({ force = false } = {}) {

View file

@ -1,6 +1,7 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import {
_getAllFlags,
_getRecentData,
@ -16,11 +17,17 @@ import {
VERSION,
} from 'src/stores/sync_config.js'
import { storage } from 'src/lib/storage.js'
describe('The SyncConfig store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('mutations', () => {
describe('initSyncConfig', () => {
const user = {
@ -163,6 +170,204 @@ describe('The SyncConfig store', () => {
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
})
it('should recover legacy preferences marked as migrated', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['modalOnUnfollow'],
modalOnUnfollow: true,
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({ ...user })
expect(store.prefsStorage.simple.modalOnUnfollow).to.eql(true)
expect(store.cache.prefsStorage.simple.modalOnUnfollow).to.eql(true)
expect(store.prefsStorage._journal).to.deep.include({
path: 'simple.modalOnUnfollow',
operation: 'set',
args: [true],
timestamp: store.prefsStorage._journal[0].timestamp,
})
expect(store.dirty).to.eql(true)
expect(store.pushSyncConfig).toHaveBeenCalledOnce()
expect(defaultState.prefsStorage.simple.modalOnUnfollow).to.eql(
undefined,
)
})
it('should preserve an explicit synced preference during recovery', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['modalOnRepeat'],
modalOnRepeat: true,
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 1,
_version: VERSION,
flagStorage: cloneDeep(defaultState.flagStorage),
prefsStorage: {
...cloneDeep(defaultState.prefsStorage),
simple: {
...cloneDeep(defaultState.prefsStorage.simple),
modalOnRepeat: false,
},
},
},
})
expect(store.prefsStorage.simple.modalOnRepeat).to.eql(false)
})
it('should preserve a journaled preference removal during recovery', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['modalOnMute'],
modalOnMute: true,
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 1,
_version: VERSION,
flagStorage: cloneDeep(defaultState.flagStorage),
prefsStorage: {
...cloneDeep(defaultState.prefsStorage),
_journal: [
{
path: 'simple.modalOnMute',
operation: 'unset',
args: [],
timestamp: 1,
},
],
},
},
})
expect(store.prefsStorage.simple.modalOnMute).to.eql(undefined)
})
it('should preserve a journaled nested preference during recovery', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['notificationVisibility'],
notificationVisibility: {
...cloneDeep(
defaultState.prefsStorage.simple.notificationVisibility,
),
likes: false,
},
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
const setPreference = vi.spyOn(store, 'setPreference')
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 1,
_version: VERSION,
flagStorage: cloneDeep(defaultState.flagStorage),
prefsStorage: {
...cloneDeep(defaultState.prefsStorage),
_journal: [
{
path: 'simple.notificationVisibility.likes',
operation: 'unset',
args: [],
timestamp: 1,
},
],
},
},
})
expect(setPreference).not.toHaveBeenCalled()
})
it('should recover with a malformed synced journal', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['modalOnUnfollow'],
modalOnUnfollow: true,
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 1,
_version: VERSION,
flagStorage: cloneDeep(defaultState.flagStorage),
prefsStorage: {
...cloneDeep(defaultState.prefsStorage),
_journal: [{ path: 1, operation: 'set' }],
},
},
})
expect(store.prefsStorage.simple.modalOnUnfollow).to.eql(true)
})
it('should not replay local theme migration during recovery', async () => {
vi.spyOn(storage, 'getItem').mockResolvedValue({
config: {
_syncMigration: ['theme3hacks'],
theme3hacks: {
underlay: 'grid',
fonts: {
interface: 'Legacy interface',
input: 'Legacy input',
post: 'Legacy posts',
monospace: 'Legacy monospace',
},
},
},
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const localStore = useLocalConfigStore()
localStore.set({ path: 'fontInterface', value: 'Current interface' })
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user })
expect(store.prefsStorage.simple.underlay).to.eql('grid')
expect(localStore.prefsStorage.fontInterface).to.eql(
'Current interface',
)
})
})
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {