fix(17): WR-02 keep one reorder save in flight and ignore stale responses

A move made while a save runs is queued and sent after it settles, so
out-of-order completion can no longer leave the database on an older tree
than the screen. A failure that a waiting save supersedes keeps the later
moves, and a response tied to an earlier load or partition leaves the
current tree and its last saved state alone.
This commit is contained in:
2026-10-12 21:32:56 +02:00
parent 964f1a7f11
commit e1ac983f1c
3 changed files with 160 additions and 17 deletions

View File

@@ -30,9 +30,13 @@ import { showToast } from '../state/useToasts'
// or inside a row, never into the node's own subtree), by the arrow keys on
// the handle and by the four buttons. There is no Save button: every move is
// saved by one POST with the whole scoped node set, 400 ms after the last
// move, and a pending save is flushed when the page is left. A failed save
// restores the last saved tree; a 409 (the records changed meanwhile)
// reloads it.
// move, and a pending save is flushed when the page is left. Only one save is
// in flight at a time: a save asked for meanwhile waits for it and then sends
// the tree as it was when asked, so the server never ends on an older tree
// than the screen. A failed save restores the last saved tree unless a later
// save (which carries the whole tree) is waiting; a 409 (the records changed
// meanwhile) reloads it. A response that arrives after the page reloaded or
// switched partition leaves the screen alone.
const SAVE_DEBOUNCE_MS = 400
const SAVED_SHOWN_MS = 2000
@@ -59,7 +63,17 @@ const saving = ref(0)
const savedShown = ref(false)
const announcement = ref('')
// One save: the tree it sends, its partition and the load generation it
// was taken from.
interface SaveJob {
sent: TreeNode[]
partition: AdminReorderPartition['value'] | null
generation: number
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
let inFlight = false
let queued: SaveJob | null = null
let savedTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let generation = 0
@@ -106,31 +120,72 @@ function showSaved(): void {
}, SAVED_SHOWN_MS)
}
async function save(): Promise<void> {
/** Saves the tree as it is now, or queues it behind the save in flight. */
function save(): void {
saveTimer = null
const sent = cloneTree(tree.value)
const body = { nodes: flatten(sent), partition: partition.value?.value ?? null } as unknown as AdminReorderRequest
const job: SaveJob = { sent: cloneTree(tree.value), partition: partition.value?.value ?? null, generation }
if (inFlight) {
queued = job
return
}
void send(job)
}
/** Whether a later move of the same page is waiting to be saved. */
function superseded(job: SaveJob): boolean {
return saveTimer !== null || (queued !== null && queued.generation === job.generation)
}
function failed(job: SaveJob): void {
if (job.generation === generation) {
if (superseded(job)) {
// The waiting save carries the whole tree, later moves included.
return
}
tree.value = cloneTree(savedTree)
}
showToast(t('backend::lang.reorder.failed'), 'danger')
}
async function send(job: SaveJob): Promise<void> {
inFlight = true
const body = { nodes: flatten(job.sent), partition: job.partition } as unknown as AdminReorderRequest
savedShown.value = false
saving.value++
try {
const { data, response } = await api.POST('/{vendor}/{plugin}/{controller}/reorder', { params: { path }, body })
const current = job.generation === generation
if (data) {
savedTree = sent
showSaved()
if (current) {
savedTree = job.sent
if (!superseded(job)) {
showSaved()
}
}
return
}
if (response.status === 409) {
showToast(t('backend::lang.reorder.stale'), 'danger')
await load()
if (current) {
// A waiting save of the same set would be refused too.
if (queued !== null && queued.generation === job.generation) {
queued = null
}
showToast(t('backend::lang.reorder.stale'), 'danger')
await load()
}
return
}
tree.value = cloneTree(savedTree)
showToast(t('backend::lang.reorder.failed'), 'danger')
failed(job)
} catch {
tree.value = cloneTree(savedTree)
showToast(t('backend::lang.reorder.failed'), 'danger')
failed(job)
} finally {
saving.value--
inFlight = false
const next = queued
queued = null
if (next) {
void send(next)
}
}
}
@@ -138,14 +193,14 @@ function scheduleSave(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => void save(), SAVE_DEBOUNCE_MS)
saveTimer = setTimeout(save, SAVE_DEBOUNCE_MS)
}
/** Sends a pending save at once (leaving the page, changing the partition). */
function flush(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
void save()
save()
}
}