Skip to content

Commit d951a8c

Browse files
committed
fix(store): Prevent out-of-order server responses to mess up the view state
Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
1 parent 140a8d7 commit d951a8c

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import axios from '@nextcloud/axios'
7+
import { createPinia, setActivePinia } from 'pinia'
8+
import { beforeEach, describe, expect, test, vi } from 'vitest'
9+
import { useViewConfigStore } from './viewConfig.ts'
10+
11+
vi.mock('@nextcloud/auth', () => ({
12+
getCurrentUser: () => ({ uid: 'test', displayName: 'Test' }),
13+
}))
14+
vi.mock('@nextcloud/axios', () => ({
15+
default: {
16+
put: vi.fn(),
17+
},
18+
}))
19+
vi.mock('@nextcloud/router', () => ({
20+
generateUrl: (url: string) => url,
21+
}))
22+
vi.mock('@nextcloud/initial-state', () => ({
23+
loadState: () => ({}),
24+
}))
25+
26+
describe('View config store keeps sorting state consistent under slow requests', () => {
27+
// Captured resolvers for the pending PUT requests, so a test can decide when
28+
// (and in which order) the "server" responds.
29+
let putResolvers: Array<() => void>
30+
31+
beforeEach(() => {
32+
setActivePinia(createPinia())
33+
putResolvers = []
34+
vi.mocked(axios.put).mockImplementation(() => new Promise((resolve) => {
35+
putResolvers.push(() => resolve({ data: {} } as never))
36+
}))
37+
})
38+
39+
test('reflects a sorting change locally without waiting for the server round-trip', () => {
40+
const store = useViewConfigStore()
41+
42+
store.setSortingBy('size', 'files')
43+
44+
// The PUT requests are still pending, yet the local config is already
45+
// updated so the header and file list can react immediately.
46+
expect(putResolvers.length).toBeGreaterThan(0)
47+
expect(store.getConfig('files')).toMatchObject({
48+
sorting_mode: 'size',
49+
sorting_direction: 'asc',
50+
})
51+
})
52+
53+
test('consecutive direction toggles read the freshly updated local state', () => {
54+
const store = useViewConfigStore()
55+
56+
// Rapid header clicks: sort by size (asc), then flip twice. Each toggle
57+
// must read the direction the previous action just wrote locally, even
58+
// though none of the PUTs have resolved yet.
59+
store.setSortingBy('size', 'files')
60+
expect(store.getConfig('files').sorting_direction).toBe('asc')
61+
62+
store.toggleSortingDirection('files')
63+
expect(store.getConfig('files').sorting_direction).toBe('desc')
64+
65+
store.toggleSortingDirection('files')
66+
expect(store.getConfig('files').sorting_direction).toBe('asc')
67+
})
68+
69+
test('an out-of-order server response does not clobber the local state', async () => {
70+
const store = useViewConfigStore()
71+
72+
store.setSortingBy('size', 'files')
73+
store.toggleSortingDirection('files')
74+
store.toggleSortingDirection('files')
75+
76+
expect(store.getConfig('files').sorting_direction).toBe('asc')
77+
78+
// Let the queued PUTs resolve in reverse order: since the local store is
79+
// no longer driven by request resolution, the latest value must survive.
80+
for (const resolve of [...putResolvers].reverse()) {
81+
resolve()
82+
}
83+
await Promise.resolve()
84+
85+
expect(store.getConfig('files').sorting_direction).toBe('asc')
86+
})
87+
})

apps/files/src/store/viewConfig.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,21 @@ export const useViewConfigStore = defineStore('viewconfig', () => {
4949
* @param value New value
5050
*/
5151
async function update(view: ViewId, key: string, value: string | number | boolean): Promise<void> {
52+
// Update the local store synchronously first, in call order, so the UI
53+
// reflects the change immediately. Emitting only after the awaited server
54+
// round-trip means rapid updates (e.g. flipping the sort direction several
55+
// times in a row) issue concurrent PUTs that may resolve out of order,
56+
// leaving the local state on whichever request finished last rather than
57+
// the user's most recent action.
58+
emit('files:view-config:updated', { view, key, value })
59+
5260
if (getCurrentUser() !== null) {
5361
await axios.put(generateUrl('/apps/files/api/v1/views'), {
5462
value,
5563
view,
5664
key,
5765
})
5866
}
59-
60-
emit('files:view-config:updated', { view, key, value })
6167
}
6268

6369
/**

0 commit comments

Comments
 (0)