fix: a routed discard names where it landed, a superseded drawer opening is dropped, and a loaded draft keeps its base in every editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 10:31:49 +02:00
co-authored by Claude Opus 5
parent c80f3f5293
commit 493448b2ce
8 changed files with 48 additions and 9 deletions
+5 -3
View File
@@ -321,8 +321,8 @@ pub struct SaveDraftResponse {
/// On `saved`: when the change was applied (client remembers it as the
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
pub current_timestamp: chrono::DateTime<chrono::Utc>,
/// `saved` upserts only: where the draft is. Differs from the URL path when
/// the item had moved away from it; the editor follows it there.
/// `saved` only: where the write landed. Differs from the URL path when the item
/// had moved away from it; the editor follows it there.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
@@ -538,7 +538,9 @@ async fn update_draft(
)
.fetch_optional(&db)
.await?
.map(|ts| (ts, None))
// Named for the same reason an upsert is: the editor that discarded is still on
// the path the item left, and reloading there would land on nothing.
.map(|ts| (ts, moved_to.clone()))
};
if let Some((ts, path)) = applied {
@@ -1163,17 +1163,24 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedFlow) return
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
diffDrawer?.openDrawer()
const currentFlow = flowStore.val
const versions = await deployedVersionOptions()
if (opening !== diffOpening) return
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
deployedLabel,
versions: await deployedVersionOptions(),
versions,
onTakeLatest,
draftBase: draftBaseVersion,
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
@@ -870,7 +870,12 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedScript) {
return
}
@@ -893,11 +898,13 @@
diffDrawer?.openDrawer()
const headHash = (deployed as { hash?: string } | undefined)?.hash
const versions = await deployedVersionOptions(headHash)
if (opening !== diffOpening) return
diffDrawer?.setDiff({
mode: 'normal',
deployed,
deployedLabel: deployedVersionLabel(deployed),
versions: await deployedVersionOptions(headHash),
versions,
onTakeLatest,
draftBase: draftBaseHash,
deployedHead: headHash,
@@ -450,7 +450,12 @@
}
}
/** Bumped per drawer opening: the fetches below are awaited, so a reopen (or a
* path change) while they run must not have the older one land last. */
let diffOpening = 0
export async function openDiffDrawer() {
const opening = ++diffOpening
if (!savedApp) {
return
}
@@ -459,10 +464,12 @@
await syncWithDeployed()
diffDrawer?.openDrawer()
const versions = await deployedVersionOptions()
if (opening !== diffOpening) return
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
versions: await deployedVersionOptions(),
versions,
onTakeLatest,
draftBase: draftBaseVersion,
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
+3 -2
View File
@@ -340,9 +340,10 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
// cached state the same way an upsert does. Listener errors must never
// make a committed save read as failed.
notifyAnySaved({ workspace: opts.workspace, itemKind: opts.itemKind, path: opts.path })
// The item had moved and the save landed where its drafts went. Last,
// The item had moved and the write landed where its drafts went — a discard
// included, since the editor that sent it is on a path the item has left. Last,
// so the editor that reacts (by leaving this path) sees a settled key.
if (opts.value !== null && resp.path && resp.path !== opts.path) {
if (resp.path && resp.path !== opts.path) {
const listeners = relocationListeners.get(key)
if (listeners) for (const l of [...listeners]) l(resp.path)
}
@@ -307,6 +307,11 @@
}
if (pendingLoad) {
backendApp = { ...backendApp, value: pendingLoad.value as App } as typeof backendApp
// Their draft's base, not ours: the prompt and the deploy guard read it, and
// deploying their content on our base would claim a version it never forked
// from. See /scripts/edit.
const theirs = (pendingLoad.value as App)?.parent_version
draftBaseVersion = theirs != null ? String(theirs) : undefined
if (hasOwnDraft) {
// AppEditor `migrateApp`s the value in place on mount (see its
// `migratedDeployedBaseline`), so the draft cell settles to the
@@ -334,7 +339,8 @@
// draft value. An existing own draft already carries it (preserved by the
// value swap above). `parent_version` is in DRAFT_COMPARE_IGNORED_FIELDS, so it
// never trips the autosave no-op / "unsaved changes" comparison.
if (!hasOwnDraft && !backendApp.no_deployed && backendApp.value) {
// Not after loading a teammate's draft either: that value carries their base.
if (!hasOwnDraft && !pendingLoad && !backendApp.no_deployed && backendApp.value) {
const versions = (backendApp as { versions?: number[] }).versions
const head = Array.isArray(versions) ? versions[versions.length - 1] : undefined
if (head != null) (backendApp.value as App).parent_version = head
@@ -404,6 +404,11 @@
? ({ ...deployedFlow, ...(pendingLoad.value as object) } as Flow)
: effectiveFlow
flow = flowToRender
if (pendingLoad) {
// Their draft's base, not ours; see /scripts/edit.
const theirs = (pendingLoad.value as { version_id?: number })?.version_id
draftBaseVersion = theirs != null ? String(theirs) : undefined
}
if (pendingLoad && hasOwnDraft) {
OtherUserDraftLoad.beginOverlay({
workspace: $workspaceStore!,
@@ -389,6 +389,10 @@
...(pendingLoad.value as object),
parent_hash: (pendingLoad.value as { parent_hash?: string })?.parent_hash ?? parentHash
} as EditableScript
// Their draft's base, not ours: the prompt and the deploy guard read these,
// and deploying their content on our base would claim a version it never
// forked from.
draftBaseHash = loadedValue.parent_hash
if (hasOwnDraft) {
OtherUserDraftLoad.beginOverlay({
workspace: $workspaceStore!,