fix(schedules): apply a toggle's result to the schedule it toggled, not the one on screen

Same rule as the save beside it, which the last commit fixed and this path did
not: the editor is reused when its drawer switches rows, so a toggle resolving
after that wrote its result into the newly opened schedule's baseline and settled
the toggled one's draft against the new form. The toggled schedule's baseline is
now read before the request, so its draft is settled from its own value whether
or not this editor still shows it, and nothing is adopted into the item that took
its place.

Also gates the variable editor's Save in create mode, where there is no cell to
key the shared window on, and reunites the settle rationale in the test file with
the block it documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-07 19:34:07 +02:00
co-authored by Claude Opus 5
parent dd9cc68362
commit 16b94ebf45
3 changed files with 42 additions and 21 deletions
@@ -291,6 +291,9 @@
const saveInFlight = $derived(
dirtyWorkspaces.some((ws) => isDraftSaving('variable', editPath, { workspace: ws }))
)
// A create has no cell to key that on — its form is the detached handle an empty
// path gets — so the one case the shared window cannot see keeps a local flag.
let creating = $state(false)
async function save(): Promise<void> {
// Everything the writes need, read before the first await. An inline host can
@@ -326,6 +329,10 @@
// is in flight for this cell, so a second editor on it cannot start one —
// checked before the first is opened, or this would see its own.
if (payloads.some(({ ws }) => isDraftSaving('variable', from, { workspace: ws }))) return
if (!from) {
if (creating) return
creating = true
}
const closeSettleWindows = from
? payloads.map(({ ws }) => beginDraftSettleWindow('variable', from, { workspace: ws }))
: []
@@ -373,11 +380,10 @@
// to it via `discard` (not `remove` — blanking the cell to `undefined` reads
// as dirty) and keeps an edit made mid-request. Each workspace settles
// against the path its own write used, not the reported one.
// The live cell only while this editor is still on the item it saved: the
// drawer is reused, so after a re-point `states` is the next item's, and
// reading it here would take that for an edit made over this write —
// leaving this item's draft behind. Re-pointed, the cell it released
// during the write is what answers.
// The live cell only while this editor is still on the item it saved: reused
// for the next one, `states` is that one's, and reading it here would take
// it for an edit made over this write. Re-pointed, the cell released
// during the write answers instead.
await settleDraftAfterWrite(
'variable',
s,
@@ -426,6 +432,7 @@
}
} finally {
for (const close of closeSettleWindows) close()
creating = false
}
}
</script>
@@ -470,7 +477,12 @@
{/if}
<Button
on:click={save}
disabled={saveInFlight || !anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
disabled={saveInFlight ||
creating ||
!anyDirty ||
!dirtyValid ||
!dirtyCanWrite ||
pathError != ''}
startIcon={{ icon: Save }}
variant="accent"
unifiedSize="sm"
@@ -855,6 +855,10 @@
const previousEnabled = enabled
const path = initialPath
const ws = wsId
// The baseline of the schedule being toggled, read before the request: this
// editor is reused for whatever the drawer opens next, and `initialConfig` is
// then that one's.
const baselineBefore = initialConfig ? ($state.snapshot(initialConfig) as any) : undefined
enabled = nEnabled
if (!trigger?.draftConfig) {
const ok = await withForkConflictRetry(
@@ -866,26 +870,31 @@
}),
'schedule'
)
// Nothing read or written below describes the toggled schedule once this
// editor has moved on — only the request's own outcome does.
const stillHere = initialPath === path && wsId === ws
if (!ok) {
enabled = previousEnabled
if (stillHere) enabled = previousEnabled
return
}
sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`)
// Deployed state moved, so the baseline the banner compares against does too.
deployedEnabled = nEnabled
if (initialConfig) {
initialConfig.enabled = nEnabled
baselineNonce++
if (stillHere) {
// Deployed state moved, so the baseline the banner compares against does too.
deployedEnabled = nEnabled
if (initialConfig) {
initialConfig.enabled = nEnabled
baselineNonce++
}
}
// Setting `enabled` above queued a draft against the pre-toggle baseline.
// It matches the new one, so nothing would ever show it again — but it is
// still a row on the server and a `*` on the list until it is settled away.
if (initialConfig)
if (baselineBefore)
await settleDraftAfterWrite(
'trigger_schedule',
$state.snapshot(initialConfig),
getScheduleCfg(),
{ ...baselineBefore, enabled: nEnabled },
stillHere ? getScheduleCfg() : undefined,
path,
path,
{ workspace: ws ?? undefined }
@@ -893,7 +902,7 @@
// This request carried the enabled flag alone, so anything else the form has
// diverged into is the user's — read after the settle, which awaits a request
// of its own with the form still editable.
onUpdate?.(path, path, ws, !draftValuesEqual(getScheduleCfg(), initialConfig))
onUpdate?.(path, path, ws, stillHere && !draftValuesEqual(getScheduleCfg(), initialConfig))
}
}
@@ -42,11 +42,6 @@ beforeEach(() => {
const OPTS = { workspace: 'ws' }
const sent = { path: 'u/me/a', value: 'sent' }
/**
* The forms stay editable while their save is in flight, so the cell can hold a
* newer edit by the time the write returns. Resetting it unconditionally — what
* every editor did — swallows that edit with no trace.
*/
// Duplicate tabs and warm sessions mount several editors over one cell, so what
// says "a write is in flight for this item" cannot live in any of them. The
// window each save opens is per key, and every editor's Save reads it.
@@ -70,6 +65,11 @@ describe('isDraftSaving', () => {
})
})
/**
* The forms stay editable while their save is in flight, so the cell can hold a
* newer edit by the time the write returns. Resetting it unconditionally — what
* every editor did — swallows that edit with no trace.
*/
describe('settleDraftAfterWrite', () => {
it('resets the cell when it still holds what was written', () => {
settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/a', OPTS)