fix(frontend): trigger autosave no longer false-restores on plain open

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-17 23:56:49 +02:00
parent 22060b708f
commit 9d326358ae
13 changed files with 187 additions and 35 deletions
@@ -25,7 +25,7 @@
import { saveAzureTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -131,7 +131,7 @@
}
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_azure', ePath)
if (localCfg && !deepEqual(localCfg, getAzureConfig())) {
if (localDraftDiffers(localCfg, getAzureConfig())) {
const deployedCfg = structuredClone($state.snapshot(getAzureConfig()))
await loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -314,7 +314,8 @@
$effect(() => {
if (drawerLoading || !initialPath) return
azureConfig && UserDraft.save('trigger_azure', initialPath, azureConfig)
azureConfig &&
UserDraft.saveIfChanged('trigger_azure', initialPath, azureConfig, originalConfig)
})
</script>
@@ -29,7 +29,7 @@
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { saveEmailTriggerFromCfg } from './utils'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -128,7 +128,7 @@
}
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_email', ePath)
if (localCfg && !deepEqual(localCfg, getEmailTriggerConfig())) {
if (localDraftDiffers(localCfg, getEmailTriggerConfig())) {
const deployedCfg = structuredClone($state.snapshot(getEmailTriggerConfig()))
loadTriggerConfig(localCfg as Partial<EmailTrigger>)
notifyRestoredFromLocal(false, true, {
@@ -311,7 +311,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_email', initialPath, emailConfig)
UserDraft.saveIfChanged('trigger_email', initialPath, emailConfig, originalConfig)
})
</script>
@@ -27,7 +27,7 @@
import { saveGcpTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -133,7 +133,7 @@
}
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_gcp', ePath)
if (localCfg && !deepEqual(localCfg, getGcpConfig())) {
if (localDraftDiffers(localCfg, getGcpConfig())) {
const deployedCfg = structuredClone($state.snapshot(getGcpConfig()))
await loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -331,7 +331,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
gcpConfig && UserDraft.save('trigger_gcp', initialPath, gcpConfig)
gcpConfig && UserDraft.saveIfChanged('trigger_gcp', initialPath, gcpConfig, originalConfig)
})
</script>
@@ -55,7 +55,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -227,7 +227,7 @@
}
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_http', ePath)
if (localCfg && !deepEqual(localCfg, getRouteConfig())) {
if (localDraftDiffers(localCfg, getRouteConfig())) {
const deployedCfg = structuredClone($state.snapshot(getRouteConfig()))
loadTriggerConfig(localCfg as Partial<HttpTrigger>)
notifyRestoredFromLocal(false, true, {
@@ -465,7 +465,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_http', initialPath, routeConfig)
UserDraft.saveIfChanged('trigger_http', initialPath, routeConfig, originalConfig)
})
</script>
@@ -24,7 +24,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -155,7 +155,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_kafka', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -369,7 +369,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_kafka', initialPath, kafkaConfig)
UserDraft.saveIfChanged('trigger_kafka', initialPath, kafkaConfig, originalConfig)
})
</script>
@@ -39,7 +39,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
interface Props {
@@ -151,7 +151,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_mqtt', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
await loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -355,7 +355,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_mqtt', initialPath, mqttConfig)
UserDraft.saveIfChanged('trigger_mqtt', initialPath, mqttConfig, originalConfig)
})
</script>
@@ -23,7 +23,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -139,7 +139,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_nats', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
await loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -339,7 +339,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_nats', initialPath, natsConfig)
UserDraft.saveIfChanged('trigger_nats', initialPath, natsConfig, originalConfig)
})
</script>
@@ -45,7 +45,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import { capitalize } from '$lib/utils'
@@ -246,7 +246,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_postgres', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
await loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -496,7 +496,8 @@
$effect(() => {
if (drawerLoading || !initialPath) return
postgresConfig && UserDraft.save('trigger_postgres', initialPath, postgresConfig)
postgresConfig &&
UserDraft.saveIfChanged('trigger_postgres', initialPath, postgresConfig, originalConfig)
})
$effect(() => {
@@ -38,8 +38,7 @@
import { runScheduleNow } from '../scheduled/utils'
import { handleConfigChange } from '../utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { twMerge } from 'tailwind-merge'
@@ -149,7 +148,7 @@
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
}
const localCfg = UserDraft.get<Record<string, any>>('trigger_schedule', ePath)
if (localCfg && !deepEqual(localCfg, getScheduleCfg())) {
if (localDraftDiffers(localCfg, getScheduleCfg())) {
// Snapshot the just-loaded backend config so "Reset to
// deployed" can re-apply it, then overlay the local autosave.
const deployedCfg = structuredClone($state.snapshot(getScheduleCfg()))
@@ -675,7 +674,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_schedule', initialPath, scheduleCfg)
UserDraft.saveIfChanged('trigger_schedule', initialPath, scheduleCfg, initialConfig)
})
</script>
@@ -31,7 +31,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
interface Props {
@@ -137,7 +137,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_sqs', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -332,7 +332,7 @@
// a path (no localStorage write would happen anyway).
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_sqs', initialPath, sqsConfig)
UserDraft.saveIfChanged('trigger_sqs', initialPath, sqsConfig, originalConfig)
})
</script>
@@ -42,7 +42,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -183,7 +183,7 @@
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
const localCfg = UserDraft.get<Record<string, any>>('trigger_websocket', ePath)
if (localCfg && !deepEqual(localCfg, getSaveCfg())) {
if (localDraftDiffers(localCfg, getSaveCfg())) {
const deployedCfg = structuredClone($state.snapshot(getSaveCfg()))
loadTriggerConfig(localCfg)
notifyRestoredFromLocal(false, true, {
@@ -423,7 +423,7 @@
$effect(() => {
if (drawerLoading || !initialPath) return
UserDraft.save('trigger_websocket', initialPath, websocketCfg)
UserDraft.saveIfChanged('trigger_websocket', initialPath, websocketCfg, originalConfig)
})
</script>
+72
View File
@@ -1,5 +1,6 @@
import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
@@ -247,6 +248,50 @@ export type UserDraftHandle<V> = {
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
}
/**
* JSON round-trip normalization. localStorage persistence stringifies the
* draft, which silently drops keys whose value is `undefined`, turns `Date`
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
* (`{ a: undefined }` `{}`). Normalize BOTH sides through the same
* round-trip before comparing. Returns the input unchanged if it can't be
* serialized (e.g. a cyclic structure) better a false "differs" than a
* throw inside a load/effect path.
*/
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
return JSON.parse(JSON.stringify(value)) as V
} catch {
return value
}
}
/**
* Whether the persisted local autosave (`localDraft`, as returned by
* `UserDraft.get`) meaningfully differs from the freshly-built
* `currentConfig`. Editor restore guards use this to decide whether to
* overlay the local autosave and toast.
*
* Returns `false` when there is no local draft. Normalizes both sides (see
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
* config e.g. one written by merely opening then closing the editor with
* no edits is correctly treated as "no meaningful draft" instead of
* spuriously triggering a restore on every reopen.
*
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
* can pass the draft straight into `loadXConfig(...)` without re-checking.
*/
export function localDraftDiffers<V>(
localDraft: V | undefined | null,
currentConfig: V
): localDraft is V {
if (localDraft === undefined || localDraft === null) return false
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
}
export const UserDraft = {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
@@ -277,6 +322,33 @@ export const UserDraft = {
}
},
/**
* Autosave gate. Persists `value` as the local draft only when it
* differs (after `normalizeForCompare`) from `deployed` the backend
* baseline the editor loaded and keeps in sync across saves (the same
* value the editor's own `hasChanged` / dirty signal compares against).
* When equal, removes any existing draft instead.
*
* Without this, an editor's autosave `$effect` fires once as soon as it
* finishes loading (the `drawerLoading` flag flips and the just-loaded
* config counts as a "change" to the effect), so merely opening and
* closing an editor with no edits would leave a no-op draft behind that
* `has()` / restore guards then treat as unsaved work forever.
*/
saveIfChanged<V>(
itemKind: UserDraftItemKind,
path: string,
value: V,
deployed: V | undefined,
opts?: UserDraftOptions
): void {
if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) {
UserDraft.remove(itemKind, path, opts)
} else {
UserDraft.save(itemKind, path, value, opts)
}
},
get<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
+80 -1
View File
@@ -15,7 +15,8 @@ vi.mock('svelte', async (importOriginal) => {
})
// Imported AFTER vi.mock so the module sees the mocked onDestroy.
const { UserDraft, __resetUserDraftForTesting } = await import('./userDraft.svelte')
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
await import('./userDraft.svelte')
const { workspaceStore } = await import('./stores')
function flushDestroyCallbacks(): void {
@@ -640,3 +641,81 @@ describe('gcUserDrafts', () => {
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull()
})
})
describe('normalizeForCompare', () => {
it('returns undefined for undefined input', () => {
expect(normalizeForCompare(undefined)).toBeUndefined()
})
it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => {
const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
expect(out).toEqual({ a: 1, c: { e: 2 } })
expect(Object.keys(out as object)).not.toContain('b')
expect(Object.keys((out as any).c)).not.toContain('d')
})
it('falls back to the original value when not serializable (cyclic)', () => {
const cyclic: any = { a: 1 }
cyclic.self = cyclic
expect(normalizeForCompare(cyclic)).toBe(cyclic)
})
})
describe('localDraftDiffers', () => {
it('returns false when there is no local draft', () => {
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
})
it('treats a draft that round-trips equal to the config as NOT differing', () => {
// The Schedule bug: getXCfg() emits conditionally-undefined keys, but
// the persisted draft went through JSON.stringify which dropped them.
const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule }
expect(localDraftDiffers(persisted, freshCfg)).toBe(false)
})
it('returns true for a genuine difference', () => {
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true)
})
})
describe('UserDraft.saveIfChanged', () => {
const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s'
it('does not persist a draft equal to the deployed baseline', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
// value is the post-load reactive cfg — same shape, undefined keys present
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('treats a value that round-trips equal to deployed as unchanged', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('persists when the value differs from the deployed baseline', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
const value = { path: 'u/me/s', schedule: '5 0 * * *' }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
expect(storedShape(KEY)).toBe(wrapped(value))
})
it('removes a pre-existing draft once the value reverts to deployed', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' })
expect(localStorage.getItem(KEY)).not.toBeNull()
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('persists when there is no deployed baseline (undefined)', () => {
const value = { path: 'u/me/s', schedule: '0 0 * * *' }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined)
expect(storedShape(KEY)).toBe(wrapped(value))
})
})