fix(drafts): defer reset-to-deployed restart until first user interaction

Two-tick `restartSync` was too aggressive: editor remounts emit a tail
of cascading writes (Monaco setValue acks, schema re-infer, UI Builder
iframe handshakes, schedule-config recomputes, …) that land well after
two ticks and would clobber the just-deleted draft with an upsert of
the deployed value — making "Reset to deployed" a no-op in practice,
the user kept seeing the draft come back.

Centralise the suspension lifecycle in a new `runResetToDeployed`
helper. It stopSyncs around the reset, POSTs the explicit delete, runs
the route's wipe-and-reload, and then arms a one-shot listener on
document keydown / input / pointerdown that restartSyncs on the user's
next real interaction. A 5-second fallback re-arms sync if the user
walks away without touching the editor, so suspensions don't leak.

Use it from both the load-time toast (`notifyDraftLoaded`) and the
autosave-indicator popover so the two stay in sync — fixes both
entry points.
This commit is contained in:
Diego Imbert
2026-06-08 01:45:34 +02:00
parent 86f5ddb84d
commit 8865c58f66
2 changed files with 73 additions and 39 deletions
@@ -1,10 +1,10 @@
<script lang="ts">
import { tick, untrack } from 'svelte'
import { untrack } from 'svelte'
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
import type { UserDraftItemKind } from '$lib/gen'
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { sendUserToast } from '$lib/toast'
import { runResetToDeployed } from '$lib/userDraftToast'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
@@ -89,22 +89,9 @@
async function resetToDeployed() {
if (!onResetToDeployed || resetting) return
resetting = true
// Mirror the `notifyDraftLoaded` toast action so the discard
// sticks: suspend sync, POST the explicit delete, run the
// route's reload, then restart sync two ticks past the
// deployed-seed write.
UserDraft.stopSync(itemKind, path, { workspace })
UserDraftDbSyncer.save({ workspace, itemKind, path, value: null }).catch((e) =>
console.error('Reset to deployed: draft delete failed', e)
)
try {
await onResetToDeployed()
} catch (e: any) {
sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true)
await runResetToDeployed({ workspace, itemKind, path, onResetToDeployed })
} finally {
await tick()
await tick()
UserDraft.restartSync(itemKind, path, { workspace })
resetting = false
popoverOpen = false
}
+70 -23
View File
@@ -4,12 +4,77 @@
* which reset actions are offered; the reset side-effects live at each call
* site (route-specific state).
*/
import { tick } from 'svelte'
import { sendUserToast } from '$lib/toast'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import type { UserDraftItemKind } from '$lib/gen'
/**
* Run a "reset to deployed" action with the reactive autosave mirror
* suspended around it. The reset's own wipe-and-reload writes flow
* through the cell and would otherwise re-create the draft we just
* deleted; suspending swallows them silently.
*
* Restart sync ONLY on the user's next real interaction (keydown / input
* / pointerdown on the document) — `tick()` alone wasn't enough because
* editors keep cascading writes for a while after they remount (Monaco
* setValue acks, schema re-infer, framework iframe acks, …) and any of
* those landing post-restart would resurrect the draft. A 5-second
* fallback re-arms sync if the user walks away without touching the
* editor, so we don't leak the suspension forever.
*
* Exported so both the load-time toast (`notifyDraftLoaded`) and the
* AutosaveIndicator popover share one implementation — keep them in
* sync by funnelling through this helper.
*/
export async function runResetToDeployed(opts: {
workspace: string
itemKind: UserDraftItemKind
path: string
onResetToDeployed: () => void | Promise<void>
}): Promise<void> {
UserDraft.stopSync(opts.itemKind, opts.path, { workspace: opts.workspace })
UserDraftDbSyncer.save({
workspace: opts.workspace,
itemKind: opts.itemKind,
path: opts.path,
value: null
}).catch((e) => console.error('Reset to deployed: draft delete failed', e))
try {
await opts.onResetToDeployed()
} catch (e: any) {
sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true)
} finally {
armRestartOnFirstInteraction(opts.workspace, opts.itemKind, opts.path)
}
}
function armRestartOnFirstInteraction(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): void {
let restarted = false
const restart = () => {
if (restarted) return
restarted = true
document.removeEventListener('keydown', restart, true)
document.removeEventListener('input', restart, true)
document.removeEventListener('pointerdown', restart, true)
clearTimeout(fallback)
UserDraft.restartSync(itemKind, path, { workspace })
}
// Capture phase + the three events that mean "the user touched the
// editor". keydown alone misses pointer-driven UI (sliders, color
// pickers, the path popover's OK button); pointerdown alone misses
// pure-keyboard edits in Monaco. Belt-and-braces fallback after 5s
// avoids stranding the suspension when the user navigates away.
document.addEventListener('keydown', restart, { capture: true, once: true })
document.addEventListener('input', restart, { capture: true, once: true })
document.addEventListener('pointerdown', restart, { capture: true, once: true })
const fallback = setTimeout(restart, 5000)
}
export type RestoreFromLocalActions = {
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
onResetToSavedDraft?: () => void | Promise<void>
@@ -68,31 +133,13 @@ export function notifyDraftLoaded(opts: {
sendUserToast('Loaded your saved draft', false, [
{
label: 'Reset to deployed',
callback: async () => {
// Suspend the reactive sync effect around the whole reset
// flow: the route's callback wipes the in-memory handle
// then re-seeds it with the deployed payload, both of
// which would otherwise fire the autosave mirror and
// resurrect the draft we just deleted. We restart sync
// only after two ticks so the deployed-seed write has
// observably advanced `lastSerialized` first.
UserDraft.stopSync(opts.itemKind, opts.path, { workspace: opts.workspace })
UserDraftDbSyncer.save({
callback: () =>
runResetToDeployed({
workspace: opts.workspace,
itemKind: opts.itemKind,
path: opts.path,
value: null
}).catch((e) => console.error('Reset to deployed: draft delete failed', e))
try {
await opts.onResetToDeployed()
} catch (e: any) {
sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true)
} finally {
await tick()
await tick()
UserDraft.restartSync(opts.itemKind, opts.path, { workspace: opts.workspace })
}
}
onResetToDeployed: opts.onResetToDeployed
})
}
])
}