From b7b18e345e6984544290009247faca1e341b46bd Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 3 Sep 2026 10:44:10 +0200 Subject: [PATCH] Revert "feat: gate drafts on real user input so a moved-on schema is not a draft" This reverts commit 6cd86cf727dc46d9836440ae345ca6161c3fb31c. --- .../src/lib/components/ResourceEditor.svelte | 72 +----------- .../triggers/useTriggerDraftSync.svelte.ts | 110 +++--------------- frontend/src/lib/userDraftEditGate.ts | 40 ------- 3 files changed, 19 insertions(+), 203 deletions(-) delete mode 100644 frontend/src/lib/userDraftEditGate.ts diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 14dc4efb72..367888655f 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -2,7 +2,7 @@ import type { Schema } from '$lib/common' import { ResourceService, WorkspaceService, type Resource, type ResourceType } from '$lib/gen' import { canWrite } from '$lib/utils' - import { createEventDispatcher, onDestroy, untrack } from 'svelte' + import { createEventDispatcher, untrack } from 'svelte' import { userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' @@ -14,7 +14,6 @@ import type { UserExt } from '$lib/stores' import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' - import { onUserInput } from '$lib/userDraftEditGate' interface Props { canSave?: boolean @@ -105,46 +104,6 @@ workspaceSpecs.push({ ws, defaultValue }) } - // A workspace stays gated until the user puts something into its form: the - // resource type's schema is what fills in properties the stored value never - // had, and that is not an edit. While gated the autosave is suspended and - // the deployed baseline absorbs whatever the form settles on, so opening a - // resource whose type gained a property leaves no draft behind. See - // `onUserInput`. A workspace opened ON a saved draft keeps its baseline — - // the divergence there is the user's own, from an earlier session. - let userEdited: Record = $state({}) - let openedOnDraft: Record = $state({}) - const suspendedWorkspaces = new Set() - - function setGated(ws: string, gated: boolean): void { - if (!initialPath) return - if (gated === suspendedWorkspaces.has(ws)) return - if (gated) { - UserDraft.stopSync('resource', initialPath, { workspace: ws }) - suspendedWorkspaces.add(ws) - } else { - UserDraft.restartSync('resource', initialPath, { workspace: ws }) - suspendedWorkspaces.delete(ws) - } - } - - onUserInput(() => { - if (selected) userEdited[selected] = true - }) - - $effect(() => { - const wss = Object.keys(states) - const edited = { ...userEdited } - untrack(() => { - for (const ws of wss) setGated(ws, !edited[ws]) - }) - }) - - // `stopSync` must be paired or the key stays unsynced for the session. - onDestroy(() => { - for (const ws of [...suspendedWorkspaces]) setGated(ws, false) - }) - let isValid = $state(true) let jsonError = $state('') let viewJsonSchema = $state(false) @@ -283,11 +242,6 @@ } // Open with the saved draft if present, else the deployed. const s: ResourceState = savedDraftState ?? deployedState - openedOnDraft[ws] = !!savedDraftState - // Gate BEFORE the handle is acquired: `stopSync` queues on a - // not-yet-live entry, and the form can settle before the effect - // above gets a chance to run. - setGated(ws, true) ensureHandle(ws, s) initialStates[ws] = structuredClone(deployedState) // Draft-only paths (`no_deployed`) have no row — saving must @@ -302,23 +256,6 @@ }) }) - // Absorb the form's settling writes into the deployed baseline while the - // selected workspace is gated, so they show up neither as the "unsaved - // changes" banner nor, once `discardIf` reads the baseline, as a draft. - // Only the selected workspace has a form rendered against it. - $effect(() => { - const ws = selected - if (!ws || !initialPath) return - if (userEdited[ws] || openedOnDraft[ws]) return - // `$state.snapshot` deep-reads, so nested `args` mutations re-run this. - const settled = states[ws]?.draft - ? ($state.snapshot(states[ws].draft) as ResourceState) - : undefined - untrack(() => { - if (settled && !draftValuesEqual(settled, initialStates[ws])) initialStates[ws] = settled - }) - }) - // Keep current.path bound to the outer `path` prop for consumers $effect(() => { if (current) path = current.path @@ -352,13 +289,6 @@ } export function discardLocalDraft(): void { if (!selected) return - // Back to the deployed value with nothing of the user's left in it, so - // the gate closes again — otherwise the form settles on the schema's - // values a second time and the discarded draft comes straight back. - // `discard` POSTs the delete itself, so suspending first is safe. - openedOnDraft[selected] = false - userEdited[selected] = false - setGated(selected, true) UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { workspace: selected }) diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index 45cda86168..ba09167d6d 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -2,21 +2,9 @@ import { untrack } from 'svelte' import { deepEqual } from 'fast-equals' import { UserDraft, normalizeDraftForCompare, type UserDraftItemKind } from '$lib/userDraft.svelte' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' -import { onUserInput } from '$lib/userDraftEditGate' type Cfg = Record -/** - * Detach a config from whatever holds it. The draft cell is deeply reactive, - * so handing its object straight to `applyCfg` would make the form's own - * `$state` (a schedule's `args`, say) the very object the cell holds — every - * later keystroke would then mutate the draft in place behind the autosave's - * back. - */ -function snapshotCfg(cfg: V): V { - return structuredClone($state.snapshot(cfg)) as V -} - /** * Whether `a` differs from `b` after `normalizeDraftForCompare` (JSON * round-trip to drop `undefined`-valued keys, plus ignored deploy-directive @@ -96,10 +84,8 @@ export interface TriggerDraftSync { * …)` (another tab, a programmatic write) propagate into the open editor. * * - **apply-effect**: reflects external `handle.draft` changes into the form. - * - **absorb-effect**: folds the form's own settling into the baseline until - * the user's first input, so a schema that moved on is not a draft. * - **persist-effect**: writes form edits back through the handle, dropping - * the draft when the form is back at the baseline. + * the draft when the form is back at the deployed baseline. * * Both effect bodies are `untrack`ed and gated by `cfgDiffers` * idempotence so they can't feed back into each other. Must be called once @@ -113,60 +99,14 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft }) const handle = $derived(handles[0]) - // The form settles on values nobody entered: the arguments `SchemaForm` - // renders come from the runnable's schema, so one that gained a property - // fills it in — an empty string, the first option of a required enum — the - // moment the drawer opens. Until the user actually puts something in, the - // baseline absorbs whatever the form settles on and nothing persists, so an - // untouched trigger is never reported as having unsaved changes. A drawer - // opened ON a restored draft absorbs nothing: that divergence is the user's - // own, from an earlier session. See `onUserInput`. - let settledBaseline: Cfg | undefined = $state(undefined) - let userEdited = $state(false) - let openedOnDraft = $state(false) - // These editors are mounted by the list page, not by the drawer, so input - // arriving while the drawer is still loading is the click that opened it - // (or anything else on the page behind) — never an edit to this form. - onUserInput(() => { - if (!opts.drawerLoading()) userEdited = true - }) - - /** The deployed config, plus whatever the form settled on by itself. */ - const baseline = $derived(settledBaseline ?? opts.deployed()) - - $effect(() => { - // A reload re-opens the gate's window: the drawer is being pointed at a - // different trigger, or the same one re-read from the backend. The click - // that opened it landed before this, hence the reset of `userEdited`. - if (!opts.drawerLoading()) return - untrack(() => { - settledBaseline = undefined - userEdited = false - openedOnDraft = false - }) - }) - - // absorb-effect: pre-edit form drift joins the baseline. - $effect(() => { - if (opts.drawerLoading() || userEdited || openedOnDraft) return - const cfg = opts.getCfg() - const deployed = opts.deployed() - if (cfg == null || deployed == null) return - // Snapshot before untracking: `getCfg` hands back the form's `$state` - // objects by reference, so only a deep read subscribes to the nested - // writes the form makes as it settles. - const settled = snapshotCfg(cfg) - untrack(() => { - if (cfgDiffers(settled, settledBaseline ?? deployed)) settledBaseline = settled - }) - }) - // Live "is there a local draft?" — the form diverges from the deployed // baseline. Gated on `!drawerLoading` (the baseline isn't settled yet // mid-load) and on a non-null baseline (a brand-new trigger has none, so // "unsaved changes" / discard-to-deployed is meaningless there). const hasDraft = $derived( - !opts.drawerLoading() && baseline != null && cfgDiffers(opts.getCfg() as Cfg, baseline as Cfg) + !opts.drawerLoading() && + opts.deployed() != null && + cfgDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg) ) // Reactive "banner is possible" — depends on `drawerLoading()` so it @@ -191,7 +131,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft if (opts.drawerLoading() || d == null) return untrack(() => { if (cfgDiffers(d, opts.getCfg() as Cfg)) { - void opts.applyCfg(snapshotCfg(d)) + void opts.applyCfg(d) } }) }) @@ -208,32 +148,30 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft discardTimer = undefined if (opts.drawerLoading()) return const cfg = opts.getCfg() + const deployed = opts.deployed() const h = handle if (!h || cfg == null) return - if (!cfgDiffers(cfg, baseline) && cfgDiffers(h.draft, baseline)) { - discard(opts.path(), baseline, true) + if (!cfgDiffers(cfg, deployed) && cfgDiffers(h.draft, deployed)) { + discard(opts.path(), deployed, true) } }, 600) } // persist-effect: form edits → handle; drop the draft when back at the - // baseline. + // deployed baseline. $effect(() => { if (opts.drawerLoading() || !opts.path()) return - // Nothing persists before the user's first input — the form's own - // settling is not an edit, and gating here rather than relying on the - // absorb-effect having run first keeps the two effects order-independent. - if (!userEdited && !openedOnDraft) return const cfg = opts.getCfg() if (cfg == null) return untrack(() => { const h = handle if (!h) return - if (cfgDiffers(cfg, baseline)) { + const deployed = opts.deployed() + if (cfgDiffers(cfg, deployed)) { if (cfgDiffers(cfg, h.draft)) h.draft = cfg - } else if (cfgDiffers(h.draft, baseline)) { - // Only when a draft actually exists to drop: `h.draft` equals the - // baseline right after a discard or the post-load seed. + } else if (cfgDiffers(h.draft, deployed)) { + // Only when a draft actually exists to drop: `h.draft` equals + // `deployed` right after a discard or the post-load seed. scheduleAutoDiscard() } }) @@ -264,7 +202,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft return hasBaseline }, get deployed() { - return baseline + return opts.deployed() }, get current() { return opts.getCfg() @@ -273,14 +211,8 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const d = handle?.draft if (cfgDiffers(d, opts.getCfg() as Cfg)) { // Overlay the local autosave on the just-loaded backend config. - await opts.applyCfg(snapshotCfg(d)) + await opts.applyCfg(d) } - // The form is not rendered while the drawer loads, so anything that - // diverges from the deployed config right now is a draft restored onto - // it — by the overlay above, or by the editor from the backend before - // calling this — never the form settling. Absorbing that into the - // baseline would hide the user's own work behind a clean drawer. - openedOnDraft = cfgDiffers(opts.getCfg() as Cfg, opts.deployed()) // Adopt the post-load form state as the cell's baseline without // POSTing, consuming the entry's one-shot first-write seed guard. // Trigger drawers never write the cell programmatically on open, so @@ -289,20 +221,14 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const p = opts.path() const cfg = opts.getCfg() if (ws && p && cfg != null) { - UserDraft.seed(opts.itemKind, p, snapshotCfg(cfg) as Cfg, { + UserDraft.seed(opts.itemKind, p, structuredClone($state.snapshot(cfg)) as Cfg, { workspace: ws }) } }, async resetToDeployed(path: string) { - const deployedCfg = snapshotCfg(opts.deployed()) as Cfg + const deployedCfg = structuredClone($state.snapshot(opts.deployed())) as Cfg discard(path, deployedCfg) - // Nothing of the user's is left in the form, so the gate closes again - // — otherwise the form settles on the schema's values a second time - // and the discarded draft comes straight back. - settledBaseline = undefined - userEdited = false - openedOnDraft = false await opts.applyCfg(deployedCfg) }, discard diff --git a/frontend/src/lib/userDraftEditGate.ts b/frontend/src/lib/userDraftEditGate.ts deleted file mode 100644 index 2593e9b9a7..0000000000 --- a/frontend/src/lib/userDraftEditGate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { onDestroy } from 'svelte' - -/** - * A draft is supposed to record what the USER changed, but an editor built - * from a schema writes into the value on its own: the form materializes a - * property the stored item never carried (an empty string, `false`, the first - * option of a required enum, a schema `default`) and deletes one a `showExpr` - * hides. So merely opening an item whose schema has moved on makes it diverge - * from the deployed value with nobody having touched it — a draft nobody asked - * for, cluttering the workspace. - * - * An editor guards against that by gating its draft on this: nothing the form - * settles on counts until the user has actually put something in. Callers - * decide what a gate covers (the resource editor keys it by workspace, since - * switching workspaces re-renders the form against a fresh value) and what - * gating means for them — suspending the autosave, absorbing the settled value - * into the deployed baseline, or both. - * - * `pointerdown` and `keydown` are the two events that precede every human - * edit, and capture phase puts this ahead of the handler that writes the - * value, so a gate opened here is already open by the time the edit lands. - * Listening on the document rather than the editor's own subtree is - * deliberate: pickers and modals render in portals outside it, and missing a - * real edit would silently drop the user's work, while opening the gate too - * eagerly only costs the phantom draft that existed before. - * - * Registers for the lifetime of the calling component — call it during init. - */ -export function onUserInput(handle: () => void): void { - if (typeof document === 'undefined') return - const onEvent = (e: Event) => { - if (e.isTrusted) handle() - } - document.addEventListener('pointerdown', onEvent, true) - document.addEventListener('keydown', onEvent, true) - onDestroy(() => { - document.removeEventListener('pointerdown', onEvent, true) - document.removeEventListener('keydown', onEvent, true) - }) -}