diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index 107721a3..b3ffe436 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -6,6 +6,7 @@ const { createDesktopSyncDiagnosticText, createDesktopSyncSummary, isStagedResourceReferenced, + stagedIdsReferencedByUnsavedContent, hasDesktopSyncStateReset, mergeMemoIdMappings, mergeSyncedMemos, @@ -68,6 +69,15 @@ describe("desktop staged resource sync", () => { ], "stage-1")).toBe(true); }); + test("keeps staged bytes while an editor draft or visible image still uses the old URL", () => { + const rewrites = [{ memoId: "memo-1", placeholder: "edgeever-staged://stage-1", url: "/api/v1/resources/res-1/blob" }]; + expect(stagedIdsReferencedByUnsavedContent(rewrites, [ + { contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "edgeever-staged://stage-1" } }] } }, + ])).toEqual(new Set(["stage-1"])); + expect(stagedIdsReferencedByUnsavedContent(rewrites, ["edgeever-staged://stage-1"])).toEqual(new Set(["stage-1"])); + expect(stagedIdsReferencedByUnsavedContent(rewrites, ["edgeever-staged://stage-10"])).toEqual(new Set()); + }); + test("retains a temporary id mapping when a later sync phase fails", () => { const retained = mergeMemoIdMappings(new Map(), new Map([["memo_local_1", "memo_remote_1"]])); diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index b9f7bdfc..2d204dfa 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -17,6 +17,7 @@ import { } from "@edgeever/shared"; import { api, ApiRequestError } from "@/lib/api"; import { isDesktopResourceRuntime, mapMarkdownResourceUrls, mapTiptapResourceUrls, toApiResourceUrl } from "@/lib/desktop-resources"; +import { readEmergencyDraft } from "@/lib/emergency-draft"; import { notebookDeleteIdsFromPayload } from "@/lib/notebook-delete"; import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events"; @@ -84,6 +85,41 @@ const stagedIdsReferencedByLocalMemos = async (rewrites: StagedResourceRewrite[] return referenced; }; +export const stagedIdsReferencedByUnsavedContent = ( + rewrites: StagedResourceRewrite[], + values: unknown[], +) => new Set(rewrites + .filter((rewrite) => isStagedResourceReferenced(values, rewrite.placeholder.slice("edgeever-staged://".length))) + .map((rewrite) => rewrite.placeholder.slice("edgeever-staged://".length))); + +const stagedIdsReferencedByRenderer = async (rewrites: StagedResourceRewrite[]) => { + if (rewrites.length === 0) return new Set(); + const memoIds = [...new Set(rewrites.map((rewrite) => rewrite.memoId))]; + try { + const { localDb } = await import("@/lib/local-db"); + const [drafts, queued] = await Promise.all([ + localDb.drafts.bulkGet(memoIds), + localDb.syncQueue.where("memoId").anyOf(memoIds).toArray(), + ]); + const visibleSources = typeof document === "undefined" ? [] : [ + ...Array.from(document.querySelectorAll("[src], [href]"), (element) => ( + element.getAttribute("src") ?? element.getAttribute("href") + )), + ...Array.from(document.querySelectorAll("textarea"), (element) => element.value), + ]; + return stagedIdsReferencedByUnsavedContent(rewrites, [ + ...drafts, + ...queued.map((item) => item.payload), + ...memoIds.map((memoId) => readEmergencyDraft(memoId)), + ...visibleSources, + ]); + } catch { + // If drafts cannot be inspected, keep the bytes until a later sync can + // prove that no editor can save the old staged URL again. + return new Set(rewrites.map((rewrite) => rewrite.placeholder.slice("edgeever-staged://".length))); + } +}; + const remapStagedResourceMemoIds = async (memoIdMappings: ReadonlyMap) => { if (memoIdMappings.size === 0) return; // Keep sync compatible with a renderer hot-reload or an older native shell @@ -748,9 +784,10 @@ export const syncDesktopData = () => { const remainingReferenced = stillReferenced.size > 0 ? await stagedIdsReferencedByLocalMemos(stagedResources.rewrites) : stillReferenced; - await removeSyncedStagedResources( - stagedResources.stagedIds.filter((stagedId) => !remainingReferenced.has(stagedId)), - ); + const rendererReferenced = await stagedIdsReferencedByRenderer(stagedResources.rewrites); + await removeSyncedStagedResources(stagedResources.stagedIds.filter((stagedId) => ( + !remainingReferenced.has(stagedId) && !rendererReferenced.has(stagedId) + ))); } phase = "read_status"; const remaining = await request("sync.status", {});