diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 1acbb0c1d205..a34a42bb44f1 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -73,6 +73,117 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); }); + it("does not rewrite already saved contents when the editor closes", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("edited"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.dispose(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + }); + + it("saves an edit made inside the debounce window when the editor closes", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("unsaved"); + coordinator.dispose(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("unsaved"); + }); + + it("flushes an edit made while a write was in flight when the editor closes", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("first"); + await vi.advanceTimersByTimeAsync(500); + coordinator.change("latest"); + coordinator.dispose(); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("latest"); + }); + + it("does not rewrite a write that lands while the editor closes", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("only"); + await vi.advanceTimersByTimeAsync(500); + coordinator.dispose(); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledOnce(); + }); + + it("retries a failed write when the editor closes", async () => { + vi.useFakeTimers(); + const persist = vi + .fn() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("write failed")))) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.dispose(); + await vi.runAllTimersAsync(); + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("latest"); + }); + it("leaves the file pending when the latest write fails", async () => { vi.useFakeTimers(); const onPendingChange = vi.fn(); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 138f01d360e3..d96f065d5317 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -11,6 +11,9 @@ export class FileSaveCoordinator { private timer: ReturnType | null = null; private latestContents = ""; private latestRevision = 0; + // Revision of the last edit confirmed on disk, so `dispose` can tell an + // unsaved edit from one the debounce already wrote and only flush the former. + private persistedRevision = 0; private lastChangeAt = 0; private saving = false; private disposed = false; @@ -28,7 +31,7 @@ export class FileSaveCoordinator { dispose(): void { this.disposed = true; this.clearTimer(); - if (this.latestRevision > 0) void this.persistLatest(); + if (this.latestRevision > this.persistedRevision) void this.persistLatest(); } private schedule(delay: number): void { @@ -54,6 +57,7 @@ export class FileSaveCoordinator { const result = await this.options.persist(contents); const succeeded = result._tag === "Success"; if (succeeded) { + this.persistedRevision = revision; this.options.onConfirmed(contents); }