Revert "feat: gate drafts on real user input so a moved-on schema is not a draft"

This reverts commit 6cd86cf727.
This commit is contained in:
Diego Imbert
2026-09-03 10:44:10 +02:00
parent 6cd86cf727
commit b7b18e345e
3 changed files with 19 additions and 203 deletions
@@ -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<string, boolean> = $state({})
let openedOnDraft: Record<string, boolean> = $state({})
const suspendedWorkspaces = new Set<string>()
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
})
@@ -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<string, any>
/**
* 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<V>(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
-40
View File
@@ -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)
})
}