diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index ff0e581e9e..f55c5cad5a 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -299,6 +299,10 @@ // linked workspace, and a rename made there is that workspace's alone. const actingPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from let actingCommitted = false + // Every workspace written, with the path it wrote there. Reported once the loop + // is done: per workspace, each carrying its own, so a linked workspace's rename + // moves the tabs acting on it and no others. + const written: { ws: string; path: string }[] = [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -346,12 +350,10 @@ await settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) - // Reported after settling, so what hears about the write sees a cell that - // has already been resolved. Per workspace, each carrying its own and the - // path it wrote there, so a linked workspace's rename moves the tabs acting - // on it and no others — and a workspace written before a later one threw is - // still reported, because it is deployed. - onSaved?.(s.path, from, ws) + // Collected, not reported yet: a report retargets the host, whose `{#key + // path}` would unmount this editor at the next workspace's await and leave + // that one's handle released — its draft then settles against nothing. + written.push({ ws, path: s.path }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -360,6 +362,7 @@ edit ? `Updated variable in ${payloads.length} workspace(s)` : `Created variable` ) dispatch('create') + for (const w of written) onSaved?.(w.path, from, w.ws) // Only while this editor is still the one that was saved: re-pointed, // `editPath` is the variable it moved to. if (editPath === from) { @@ -370,6 +373,8 @@ } } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) + // Whatever committed before the throw is deployed, so it is still reported. + for (const w of written) onSaved?.(w.path, from, w.ws) // Reopened, so the handles come from the server: re-keying them would bring // back the values this drawer opened on, which describe neither what the // committed workspace now has deployed nor the draft the failed one still diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 685e739773..33e1326b0a 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -638,6 +638,10 @@ // remounting: the form stays editable during the write, and a remount would // re-read over an edit made then — which `settleDraftAfterWrite` keeps. initialConfig = structuredClone(scheduleCfg) + // An update's payload carries no `enabled` (see saveScheduleFromCfg): the + // toggle deploys that field on its own, so the deployed value is whatever it + // last set, not what this save's click-time snapshot happens to hold. + if (!wasCreate) initialConfig.enabled = enabled // An edit made while the write was in flight is kept rather than settled away // — and the host must not remount over it, which is the only thing that can // tell it so. diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 1f4f0da9df..2706a29f32 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -295,10 +295,9 @@ export async function settleDraftAfterWrite( } /** - * Send whatever this cell has parked on the autosave debounce, now. For a reader - * that cannot be corrected once it has read — a list page inside an iframe, whose - * draft markers come from its own store — and would otherwise render a row the - * debounce has not caught up with. + * Send whatever this cell has parked on the autosave debounce, now. For a caller + * that has to know the cell is resolved before it acts — settling after a write, + * before anything reports or remounts on it. */ export async function flushDraftWrites( itemKind: UserDraftItemKind, diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index f92c8373e4..a20286b19b 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -636,9 +636,10 @@ const entity = parseEntityEditorRoute(loc) // A bare list tab shows every row, so any write on its page is its business // — same as for a chat mutation, and its rows and `*` markers go stale - // otherwise. + // otherwise. Queued rather than reloaded here, so one deploy's writes and + // this report collapse into a single reload. if (!entity) { - if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + queuePageReload(page) continue } if (entity.path !== ev.path) continue @@ -654,6 +655,14 @@ } } } + // One queue for every reason a previewed list page goes stale — a chat tool, a + // hosted editor's write, a draft landing afterwards — so the same frame is not + // reloaded several times for one deploy. + function queuePageReload(page: string) { + pendingPages.add(page) + clearTimeout(reloadHandle) + reloadHandle = setTimeout(flushReload, 500) + } function flushReload() { const pages = pendingPages const mutations = pendingMutations @@ -665,37 +674,11 @@ // nothing we do afterwards corrects what it already read. Rather than time our // reloads against writes, reload when one lands: a draft written after a save — // an edit made while it was in flight — reaches the list this way too. - let draftLandedHandle: ReturnType | undefined - let draftLandedPages = new Set() $effect(() => { - const stop = UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => { + return UserDraftDbSyncer.onAnySaved(({ itemKind }) => { const page = entityListPage(itemKind as EntityEditorKind)?.path - if (!page) return - draftLandedPages.add(`${workspace}\u0000${page}`) - clearTimeout(draftLandedHandle) - // Coalesced: a deploy lands several writes for one item back to back. - draftLandedHandle = setTimeout(() => { - const pages = draftLandedPages - draftLandedPages = new Set() - for (const s of warmSessions) { - const ws = getEffectiveWorkspaceId(s) - const owner = getRuntime(s.id)?.previewTabs - if (!ws || !owner) continue - for (const tab of owner.tabs) { - const loc = whereIs(tab) - if (parseEntityEditorRoute(loc)) continue - if (!pages.has(`${ws}\u0000${stripBase(loc)}`)) continue - const key = tabKey(s.id, tab.id) - if (mountedTabKeys.has(key)) tabHosts[key]?.reload() - } - } - }, 300) + if (page) queuePageReload(page) }) - return () => { - clearTimeout(draftLandedHandle) - draftLandedPages = new Set() - stop() - } }) $effect(() => {