feat(frontend): staleness modal for the script editor's local autosave

Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.

Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.

The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.
This commit is contained in:
Diego Imbert
2026-05-12 22:42:50 +02:00
parent 946dfb12f4
commit a32f257686
4 changed files with 305 additions and 41 deletions
@@ -0,0 +1,106 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
type Cause = 'draft' | 'version'
let {
open = false,
cause = 'version',
onLoadLatest,
onKeepDraft
}: {
open?: boolean
/** What changed on the remote since the local draft was created. */
cause?: Cause
onLoadLatest: () => void | Promise<void>
onKeepDraft: () => void | Promise<void>
} = $props()
function onKeyDown(event: KeyboardEvent) {
if (!open) return
event.stopPropagation()
event.preventDefault()
switch (event.key) {
case 'Enter':
onLoadLatest()
break
case 'Escape':
onKeepDraft()
break
}
}
function fadeFast(node: HTMLElement) {
return fade(node, { duration: 100 })
}
const title = $derived(
cause === 'draft'
? 'A newer draft was saved on the server'
: 'A newer version was deployed on the server'
)
const body = $derived(
cause === 'draft'
? "Someone else (or another tab) pushed a draft for this item while you were editing locally. Your local autosave is now older than what's on the server."
: "A new version was deployed for this item while you were editing locally. Your local autosave is now older than what's deployed."
)
</script>
<svelte:window onkeydowncapture={onKeyDown} />
{#if open}
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
<div
class={classNames(
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
)}
></div>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class={classNames(
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
open
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
)}
>
<div class="flex">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
>
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
</div>
<div class="ml-4 flex-1 text-left">
<h3 class="text-lg font-medium text-primary">{title}</h3>
<p class="mt-2 text-sm text-secondary">{body}</p>
</div>
</div>
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
<Button
on:click={() => onLoadLatest()}
color="dark"
size="sm"
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
variant="accent"
>
<span class="min-w-20">Load latest version</span>
</Button>
<Button
on:click={() => onKeepDraft()}
variant="default"
size="sm"
shortCut={{ key: 'Esc', withoutModifier: true }}
>
Keep current draft
</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
+61 -2
View File
@@ -113,6 +113,55 @@ function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
return meta
}
/**
* Compares the rev metadata recorded against the local draft to the current
* backend revs. Returns the staleness cause, or `null` when the local draft
* is still based on the latest backend state we know about.
*
* - Entries with no recorded meta (legacy entries written before this field
* existed) report `null` — we can't tell if they're stale, and we'd rather
* trust the local autosave than spam the user with false positives.
* - DB-draft staleness wins over deployed-version staleness: a remote DB
* draft is the more recent state to reconcile against.
* - If a DB draft existed when the local autosave was created but now no
* longer exists on the remote (someone discarded it), we report `version`
* because the deployed version is now the canonical "latest saved".
*/
export type UserDraftStalenessCause = 'draft' | 'version'
export function checkStaleness(
meta: UserDraftMeta,
currentRev: string | number | undefined,
currentDraftRev?: string | number | undefined
): UserDraftStalenessCause | null {
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
if (meta.remoteDraftRev !== currentDraftRev) {
return currentDraftRev !== undefined ? 'draft' : 'version'
}
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
return null
}
/**
* Synchronously write to localStorage, bypassing the entry's setter. Used by
* `setMeta({ force: true })` so a "Keep current draft" modal acknowledgement
* persists even when it happens to be the first state mutation of the entry
* (which `useLocalStorageValue`'s `saveInitialValue: false` contract would
* otherwise skip).
*/
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
try {
const next = wrap(value, meta)
if (next === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
} catch (e) {
console.error('UserDraft: localStorage write failed', e)
}
}
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
try {
const raw = localStorage.getItem(key)
@@ -172,8 +221,15 @@ export type UserDraftHandle<V> = {
* Used after the "Keep current draft" modal action to ack the new remote
* rev so the staleness modal doesn't fire again until the remote moves
* further.
*
* Pass `{ force: true }` to also persist the new meta to localStorage
* synchronously, bypassing `saveInitialValue: false`'s skip of the first
* state mutation. Without `force`, an ack that happens to be the first
* write on the entry would only update in-memory state and be lost on
* next mount — re-triggering the staleness modal even after the user
* just acknowledged it.
*/
setMeta(meta: UserDraftMeta): void
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
}
export const UserDraft = {
@@ -314,10 +370,13 @@ export const UserDraft = {
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
sharedEntry.state.val = wrap(value, meta)
},
setMeta(meta: UserDraftMeta): void {
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
const current = sharedEntry.state.val as StoredDraft<V> | undefined
if (current === undefined) return
sharedEntry.state.val = wrap(current.value, meta)
if (opts?.force && !isLocalOnly(path)) {
persistDirect(localStorageKey(ws, itemKind, path), current.value, meta)
}
}
}
}
+62
View File
@@ -364,6 +364,68 @@ describe('UserDraft — rev metadata for staleness checks', () => {
expect(handle.draft).toBe('no-rev')
expect(handle.meta).toEqual({})
})
it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/forceack',
JSON.stringify({ value: 'edited', remoteRev: 'v1' })
)
const handle = UserDraft.use<string>('flow', 'u/me/forceack')
// Without force, this is the entry's first state mutation and gets
// swallowed by saveInitialValue=false — localStorage would still
// hold the old remoteRev.
handle.setMeta({ remoteRev: 'v2' }, { force: true })
expect(handle.meta).toEqual({ remoteRev: 'v2' })
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
)
})
})
describe('checkStaleness', () => {
let checkStaleness: (
meta: { remoteRev?: string | number; remoteDraftRev?: string | number },
currentRev: string | number | undefined,
currentDraftRev?: string | number | undefined
) => 'draft' | 'version' | null
beforeEach(async () => {
// Re-import to dodge ESM caching surprises across test files.
;({ checkStaleness } = await import('./userDraft.svelte'))
})
it('returns null for legacy entries with no recorded rev', () => {
expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull()
})
it('returns null when meta matches current revs exactly', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull()
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull()
})
it('returns "draft" when a newer DB draft was pushed on the remote', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft')
})
it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => {
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft')
})
it('returns "version" when the deployed rev moved and draft revs match', () => {
expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version')
})
it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe(
'version'
)
})
it('prefers "draft" over "version" when both have changed', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft')
})
})
describe('UserDraft.use() — reference counting & cleanup', () => {
@@ -9,12 +9,13 @@
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -41,6 +42,43 @@
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
// Local-draft staleness modal: opened when the remote (deployed or DB
// draft) has moved on since the user's autosave was created.
let staleModalOpen = $state(false)
let staleModalCause = $state<'draft' | 'version'>('version')
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
function applyBaseline(baseline: EditableScript): void {
initialPath = baseline.path
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
scriptBuilder?.setCode(baseline.content)
if (baseline['primary_schedule']) {
savedPrimarySchedule = baseline['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
}
function onStaleLoadLatest(): void {
if (!pendingBaseline) {
staleModalOpen = false
return
}
const { baseline, revs } = pendingBaseline
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(baseline, revs)
applyBaseline(baseline)
pendingBaseline = undefined
staleModalOpen = false
}
function onStaleKeepDraft(): void {
if (pendingBaseline) {
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
}
pendingBaseline = undefined
staleModalOpen = false
}
/** Increments per `loadScript` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
* after their captured token no longer matches. */
@@ -65,9 +103,14 @@
savedScript = structuredClone($state.snapshot(scriptWithDraft))
const localDraft = scriptHandle.draft
const previousMeta = scriptHandle.meta
const backendDraft = scriptWithDraft.draft
? ({ ...scriptWithDraft.draft } as EditableScript)
: undefined
const newRevs: UserDraftMeta = {
remoteRev: scriptWithDraft.hash,
remoteDraftRev: scriptWithDraft.draft_created_at
}
// Compute the fully-baked initial value once so the assignment
// below is a single write — otherwise post-load mutations like
@@ -81,64 +124,52 @@
}
if (localDraft != undefined) {
// Local autosave wins; offer a diff against the latest saved
// version (backend draft if any, otherwise deployed) so the
// user can discard it.
const reference = backendDraft ?? scriptWithDraft
const referenceClean = cleanValueProperties(reference)
const localClean = cleanValueProperties(localDraft)
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
// Local matches the saved version — silently drop it and use the saved one.
scriptHandle.draft = bakedBaseline
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
} else {
sendUserToast('Script loaded from local autosave', false, [
{
label: 'Discard local autosave',
callback: () => {
scriptHandle.draft = bakedBaseline
}
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: referenceClean,
current: localClean,
title: `${backendDraft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: {
text: 'Discard autosave',
onClick: () => {
scriptHandle.draft = bakedBaseline
}
}
})
}
}
])
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
// Remote moved on since the local autosave was written —
// surface the choice via modal. The local draft stays on
// screen until the user picks.
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
staleModalCause = cause
staleModalOpen = true
} else if (
previousMeta.remoteRev === undefined &&
previousMeta.remoteDraftRev === undefined
) {
// Legacy entry (no meta recorded) — backfill so future
// loads can detect staleness even if the user doesn't edit.
scriptHandle.setMeta(newRevs, { force: true })
}
}
} else if (backendDraft) {
scriptHandle.draft = bakedBaseline
if (scriptHandle.draft?.['primary_schedule']) {
savedPrimarySchedule = scriptHandle.draft['primary_schedule']
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
if (bakedBaseline['primary_schedule']) {
savedPrimarySchedule = bakedBaseline['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
if (!scriptWithDraft.draft_only) {
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: scriptHandle.draft!.path
path: bakedBaseline.path
})
UserDraft.remove('script', draftPath)
goto(`/scripts/edit/${scriptHandle.draft!.path}`)
goto(`/scripts/edit/${bakedBaseline.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(scriptHandle.draft)
const draft = cleanValueProperties(bakedBaseline)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Discard draft reset to deployed version',
@@ -160,7 +191,7 @@
])
}
} else {
scriptHandle.draft = bakedBaseline
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
}
}
@@ -214,6 +245,12 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if scriptHandle.draft}
<ScriptBuilder
bind:this={scriptBuilder}