fix(settings): serialize reads with pending saves
This commit is contained in:
1 parent
9aaaf15278
commit
f7584d1089
2 files changed
+109
No files matched your search
@@ -223,6 +223,109 @@ describe('设置同步顺序', () => {
|
|||||||
expect(localStorage.getItem('rawmark.settings')).toBe(cache)
|
expect(localStorage.getItem('rawmark.settings')).toBe(cache)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['settings', 'fonts'] as const)('旧%s响应晚到时,不恢复已删除字体或覆盖新设置与缓存', async (delayed) => {
|
||||||
|
const font = { id: 'custom-' + 'c'.repeat(64), name: '已删除字体', format: 'woff2' as const }
|
||||||
|
const oldSettings = { uiFont: font.id, textFont: font.id, codeFont: font.id, fontSize: 18 }
|
||||||
|
let release!: () => void
|
||||||
|
const pending = new Promise<void>(resolve => { release = resolve })
|
||||||
|
vi.mocked(api.settings).mockImplementationOnce(async () => {
|
||||||
|
if (delayed === 'settings') await pending
|
||||||
|
return oldSettings
|
||||||
|
})
|
||||||
|
vi.mocked(api.fonts).mockImplementationOnce(async () => {
|
||||||
|
if (delayed === 'fonts') await pending
|
||||||
|
return [font]
|
||||||
|
})
|
||||||
|
const oldLoad = loadSettings()
|
||||||
|
vi.mocked(api.settings).mockResolvedValue({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 })
|
||||||
|
await loadSettings()
|
||||||
|
const cache = localStorage.getItem('rawmark.settings')
|
||||||
|
release()
|
||||||
|
await oldLoad
|
||||||
|
expect(settings).toMatchObject({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 })
|
||||||
|
expect(customFonts.value).toEqual([])
|
||||||
|
expect(document.querySelector('#rm-custom-fonts')!.textContent).not.toContain(font.id)
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--rm-font-text')).not.toContain(font.id)
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--rm-font-size')).toBe('24px')
|
||||||
|
expect(localStorage.getItem('rawmark.settings')).toBe(cache)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['settings', 'fonts'] as const)('本地保存完成后,迟到的旧%s响应不覆盖界面、缓存或清理期限', async delayed => {
|
||||||
|
let release!: () => void
|
||||||
|
const pending = new Promise<void>(resolve => { release = resolve })
|
||||||
|
vi.mocked(api.settings).mockImplementationOnce(async () => {
|
||||||
|
if (delayed === 'settings') await pending
|
||||||
|
return { fontSize: 16, trashRetentionDays: 30 }
|
||||||
|
})
|
||||||
|
vi.mocked(api.fonts).mockImplementationOnce(async () => {
|
||||||
|
if (delayed === 'fonts') await pending
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const oldLoad = loadSettings()
|
||||||
|
await updateSettings({ fontSize: 24, trashRetentionDays: 0 })
|
||||||
|
const cache = localStorage.getItem('rawmark.settings')
|
||||||
|
release()
|
||||||
|
await oldLoad
|
||||||
|
expect(settings).toMatchObject({ fontSize: 24, trashRetentionDays: 0 })
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--rm-font-size')).toBe('24px')
|
||||||
|
expect(localStorage.getItem('rawmark.settings')).toBe(cache)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存排队期间的同步等待全部已有写入,不提前读取旧配置', async () => {
|
||||||
|
let finishFirst!: (value: Record<string, unknown>) => void
|
||||||
|
let finishSecond!: (value: Record<string, unknown>) => void
|
||||||
|
const pendingFirst = new Promise<Record<string, unknown>>(resolve => { finishFirst = resolve })
|
||||||
|
const pendingSecond = new Promise<Record<string, unknown>>(resolve => { finishSecond = resolve })
|
||||||
|
vi.mocked(api.patchSettings)
|
||||||
|
.mockImplementationOnce(() => pendingFirst)
|
||||||
|
.mockImplementationOnce(() => pendingSecond)
|
||||||
|
vi.mocked(api.settings).mockResolvedValue({ fontSize: 24, trashRetentionDays: 0 })
|
||||||
|
const first = updateSettings({ fontSize: 24 })
|
||||||
|
const second = updateSettings({ trashRetentionDays: 0 })
|
||||||
|
const sync = loadSettings()
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledTimes(1))
|
||||||
|
expect(api.settings).not.toHaveBeenCalled()
|
||||||
|
finishFirst({})
|
||||||
|
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledTimes(2))
|
||||||
|
expect(api.settings).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
finishFirst({})
|
||||||
|
finishSecond({})
|
||||||
|
await Promise.all([first, second, sync])
|
||||||
|
}
|
||||||
|
expect(api.settings).toHaveBeenCalledTimes(1)
|
||||||
|
expect(settings).toMatchObject({ fontSize: 24, trashRetentionDays: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('等待保存中的读取被后续本地修改作废,不回写旧设置', async () => {
|
||||||
|
let finish!: (value: Record<string, unknown>) => void
|
||||||
|
vi.mocked(api.patchSettings).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||||
|
const first = updateSettings({ fontSize: 22 })
|
||||||
|
const sync = loadSettings()
|
||||||
|
const second = updateSettings({ fontSize: 24 })
|
||||||
|
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledTimes(1))
|
||||||
|
finish({})
|
||||||
|
await Promise.all([first, second, sync])
|
||||||
|
expect(api.settings).not.toHaveBeenCalled()
|
||||||
|
expect(settings.fontSize).toBe(24)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('等待中的保存失败后仍可读取服务端真值,保留原保存错误', async () => {
|
||||||
|
let fail!: (cause: Error) => void
|
||||||
|
vi.mocked(api.patchSettings).mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
|
||||||
|
vi.mocked(api.settings).mockResolvedValue({ fontSize: 18 })
|
||||||
|
const cause = new ApiError(500, '保存未完成')
|
||||||
|
const write = updateSettings({ fontSize: 24 }).catch(error => error)
|
||||||
|
const sync = loadSettings()
|
||||||
|
await vi.waitFor(() => expect(fail).toBeTypeOf('function'))
|
||||||
|
fail(cause)
|
||||||
|
expect(await write).toBe(cause)
|
||||||
|
await sync
|
||||||
|
expect(settings.fontSize).toBe(18)
|
||||||
|
expect(settingsSave).toMatchObject({ pending: 0, result: 'error', error: cause.message })
|
||||||
|
})
|
||||||
|
|
||||||
it('最新同步失败仍报告原错误,迟到旧响应不会覆盖当前状态,后续可重新同步', async () => {
|
it('最新同步失败仍报告原错误,迟到旧响应不会覆盖当前状态,后续可重新同步', async () => {
|
||||||
let release!: (value: Record<string, unknown>) => void
|
let release!: (value: Record<string, unknown>) => void
|
||||||
vi.mocked(api.settings).mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
|
vi.mocked(api.settings).mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
|
|||||||
let settingsRequest = 0
|
let settingsRequest = 0
|
||||||
export async function loadSettings() {
|
export async function loadSettings() {
|
||||||
const request = ++settingsRequest
|
const request = ++settingsRequest
|
||||||
|
if (settingsSave.pending) {
|
||||||
|
// 推送可能先于 PATCH 响应到达;先等待现有写入,避免读取未保存的旧值。
|
||||||
|
await saveQueue
|
||||||
|
if (request !== settingsRequest) return
|
||||||
|
}
|
||||||
const [raw, fonts] = await Promise.all([api.settings(), api.fonts()])
|
const [raw, fonts] = await Promise.all([api.settings(), api.fonts()])
|
||||||
// 目录与设置作为一批应用,迟到旧响应不能恢复已删除字体或覆盖新设置。
|
// 目录与设置作为一批应用,迟到旧响应不能恢复已删除字体或覆盖新设置。
|
||||||
if (request !== settingsRequest) return
|
if (request !== settingsRequest) return
|
||||||
@@ -113,6 +118,7 @@ export async function reloadTheme() {
|
|||||||
|
|
||||||
/** 修改设置:先在本地生效再保存,保存失败时由调用方提示并重新载入 */
|
/** 修改设置:先在本地生效再保存,保存失败时由调用方提示并重新载入 */
|
||||||
export async function updateSettings(patch: Partial<Settings>) {
|
export async function updateSettings(patch: Partial<Settings>) {
|
||||||
|
++settingsRequest
|
||||||
applied({ ...settings, ...patch })
|
applied({ ...settings, ...patch })
|
||||||
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
|
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
|
||||||
await saveSettings(patch)
|
await saveSettings(patch)
|
||||||
|
|||||||
Reference in new issue
Block a user