From 09610fe2a7a3aebc761ea06ed03cd29ba576d78b Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Mon, 15 Jun 2026 11:40:38 +0300 Subject: [PATCH] fix(drawings): keep saved scene content in renderer state --- .../drawings/__tests__/useDrawings.test.ts | 164 ++++++++++++++++++ .../spaces/drawings/useDrawings.ts | 34 +++- 2 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 src/renderer/composables/spaces/drawings/__tests__/useDrawings.test.ts diff --git a/src/renderer/composables/spaces/drawings/__tests__/useDrawings.test.ts b/src/renderer/composables/spaces/drawings/__tests__/useDrawings.test.ts new file mode 100644 index 00000000..758760fd --- /dev/null +++ b/src/renderer/composables/spaces/drawings/__tests__/useDrawings.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, nextTick, ref } from 'vue' + +const invoke = vi.fn() +const appStore = new Map() +const markPersistedStorageMutation = vi.fn() +const markUserEdit = vi.fn() +const routerPush = vi.fn() + +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) +vi.stubGlobal('nextTick', nextTick) + +vi.mock('@/electron', () => ({ + ipc: { + invoke, + }, + store: { + app: { + get: (key: string) => appStore.get(key), + set: (key: string, value: unknown) => appStore.set(key, value), + }, + }, +})) + +vi.mock('@/router', () => ({ + router: { + push: routerPush, + }, + RouterName: { + drawingsSpace: 'drawings-space', + }, +})) + +vi.mock('@/composables/useDonations', () => ({ + useDonations: () => ({ + incrementCreated: vi.fn(), + }), +})) + +vi.mock('@/composables/useStorageMutation', () => ({ + markPersistedStorageMutation, + markUserEdit, +})) + +describe('useDrawings', () => { + const drawing = { + id: 'Sketch', + name: 'Sketch', + createdAt: 1, + updatedAt: 1, + } + const otherDrawing = { + id: 'Other', + name: 'Other', + createdAt: 1, + updatedAt: 1, + } + const emptyContent = '{"type":"excalidraw","elements":[]}' + const otherContent = '{"type":"excalidraw","elements":[{"id":"other"}]}' + const updatedContent = '{"type":"excalidraw","elements":[{"id":"shape"}]}' + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + appStore.clear() + vi.stubGlobal('window', { + dispatchEvent: vi.fn(), + }) + vi.stubGlobal( + 'CustomEvent', + class { + detail: unknown + type: string + + constructor(type: string, init?: { detail?: unknown }) { + this.type = type + this.detail = init?.detail + } + }, + ) + }) + + it('keeps active drawing content current while an IPC save is pending', async () => { + let resolveWrite: ((value: { updatedAt: number }) => void) | undefined + + invoke.mockImplementation((channel: string) => { + if (channel === 'spaces:drawings:list') { + return Promise.resolve([drawing]) + } + + if (channel === 'spaces:drawings:read') { + return Promise.resolve(emptyContent) + } + + if (channel === 'spaces:drawings:write') { + return new Promise<{ updatedAt: number }>((resolve) => { + resolveWrite = resolve + }) + } + + return Promise.resolve(null) + }) + + const { useDrawings } = await import('../useDrawings') + const drawings = useDrawings() + + await drawings.init() + + expect(drawings.activeDrawingContent.value).toBe(emptyContent) + + const savePromise = drawings.saveDrawingContent(drawing.id, updatedContent) + + expect(drawings.activeDrawingContent.value).toBe(updatedContent) + expect(markUserEdit).toHaveBeenCalledTimes(1) + + resolveWrite?.({ updatedAt: 2 }) + await savePromise + + expect(drawings.activeDrawingContent.value).toBe(updatedContent) + }) + + it('uses pending content when a drawing is reselected before its save finishes', async () => { + let resolveWrite: ((value: { updatedAt: number }) => void) | undefined + const savedContentById: Record = { + [drawing.id]: emptyContent, + [otherDrawing.id]: otherContent, + } + + invoke.mockImplementation((channel: string, payload?: { id?: string }) => { + if (channel === 'spaces:drawings:list') { + return Promise.resolve([drawing, otherDrawing]) + } + + if (channel === 'spaces:drawings:read' && payload?.id) { + return Promise.resolve(savedContentById[payload.id]) + } + + if (channel === 'spaces:drawings:write') { + return new Promise<{ updatedAt: number }>((resolve) => { + resolveWrite = resolve + }) + } + + return Promise.resolve(null) + }) + + const { useDrawings } = await import('../useDrawings') + const drawings = useDrawings() + + await drawings.init() + + const savePromise = drawings.saveDrawingContent(drawing.id, updatedContent) + + await drawings.selectDrawing(otherDrawing.id) + await drawings.selectDrawing(drawing.id) + + expect(drawings.activeDrawingContent.value).toBe(updatedContent) + + savedContentById[drawing.id] = updatedContent + resolveWrite?.({ updatedAt: 2 }) + await savePromise + }) +}) diff --git a/src/renderer/composables/spaces/drawings/useDrawings.ts b/src/renderer/composables/spaces/drawings/useDrawings.ts index 97fa24fe..b0140ea8 100644 --- a/src/renderer/composables/spaces/drawings/useDrawings.ts +++ b/src/renderer/composables/spaces/drawings/useDrawings.ts @@ -26,6 +26,7 @@ let initialized = false let lastSavedContent: string | null = null let inFlightSaves = 0 let loadContentToken = 0 +const pendingContentByDrawingId = new Map() const activeDrawing = computed(() => { return drawings.value.find(item => item.id === activeDrawingId.value) @@ -83,8 +84,11 @@ async function loadActiveDrawingContent() { return } - activeDrawingContent.value = typeof content === 'string' ? content : null - lastSavedContent = activeDrawingContent.value + const savedContent = typeof content === 'string' ? content : null + + activeDrawingContent.value + = pendingContentByDrawingId.get(id) ?? savedContent + lastSavedContent = savedContent sceneRevision.value += 1 } @@ -251,6 +255,13 @@ export function useDrawings() { persistViewports() } + const pendingContent = pendingContentByDrawingId.get(id) + + if (pendingContent && record.id !== id) { + pendingContentByDrawingId.delete(id) + pendingContentByDrawingId.set(record.id, pendingContent) + } + if (activeDrawingId.value === id) { activeDrawingId.value = record.id persistSelection() @@ -296,6 +307,7 @@ export function useDrawings() { persistViewports() } + pendingContentByDrawingId.delete(id) notifyDrawingsChanged(id) if (activeDrawingId.value === id) { @@ -310,18 +322,30 @@ export function useDrawings() { return } - if (id === activeDrawingId.value && content === lastSavedContent) { - return + const isActiveDrawing = id === activeDrawingId.value + + if (isActiveDrawing) { + activeDrawingContent.value = content + + if (content === lastSavedContent) { + return + } } + pendingContentByDrawingId.set(id, content) markUserEdit() markPersistedStorageMutation() inFlightSaves += 1 try { const record = await ipc.invoke('spaces:drawings:write', { id, content }) + const pendingContent = pendingContentByDrawingId.get(id) + + if (pendingContent === content) { + pendingContentByDrawingId.delete(id) + } - if (id === activeDrawingId.value) { + if (id === activeDrawingId.value && pendingContent === content) { lastSavedContent = content }