Files
windmill/frontend/src/lib/userDraftToast.ts
T
Diego Imbert 8865c58f66 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.
2026-06-08 01:45:34 +02:00

146 lines
5.5 KiB
TypeScript

/**
* "Restored from local storage" toast, shown when an editor reopens on a
* local autosave that differs from the backend. Owns only the wording and
* which reset actions are offered; the reset side-effects live at each call
* site (route-specific state).
*/
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>
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
onResetToDeployed?: () => void | Promise<void>
}
/** Show the toast with up to two reset actions, gated by what the backend has. */
export function notifyRestoredFromLocal(
hasBackendDraft: boolean,
hasDeployed: boolean,
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
): void {
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
if (hasBackendDraft && onResetToSavedDraft) {
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
}
if (hasDeployed && onResetToDeployed) {
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
}
if (actions.length === 0) return
sendUserToast('Restored from local storage', false, actions)
}
/**
* Shown when an editor mounts on a per-user draft fetched from the server
* (the `get_draft=true` overlay returned `is_draft: true`).
*
* Offers a "Reset to deployed" action UNLESS `draftOnly` is set — when
* the response came back via `fetch_draft_only` there is no deployed row
* at this path, so falling back has nothing to fall back to. In that
* case we still show the toast (the user should know they're editing a
* draft) but omit the action.
*
* The action:
* 1. Fires `value: null` at the syncer (fire-and-forget — the user's
* delete races no reader; `onResetToDeployed` doesn't depend on it).
* 2. invokes `onResetToDeployed`, which MUST fetch deployed directly
* (e.g. `getDraft: false`) instead of trusting that the delete has
* already landed in the DB.
*
* A delete failure logs to console only — there's no UI state that
* depends on it. A reset-callback failure surfaces a toast.
*/
export function notifyDraftLoaded(opts: {
workspace: string
itemKind: UserDraftItemKind
path: string
draftOnly?: boolean
onResetToDeployed: () => void | Promise<void>
}): void {
if (opts.draftOnly) {
sendUserToast('Loaded your saved draft')
return
}
sendUserToast('Loaded your saved draft', false, [
{
label: 'Reset to deployed',
callback: () =>
runResetToDeployed({
workspace: opts.workspace,
itemKind: opts.itemKind,
path: opts.path,
onResetToDeployed: opts.onResetToDeployed
})
}
])
}