From 77c1f5385ef4596d22bf277a73e0b35f214d5e3d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 13 Oct 2026 12:54:25 +0900 Subject: [PATCH] fix(attachment): reclaim applied previews on unmount --- .changeset/tidy-otters-rest.md | 6 ++++ .../attachment/use-attachment.test.ts | 30 ++++++++++++++++--- .../components/attachment/use-attachment.ts | 24 +++++++-------- 3 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 .changeset/tidy-otters-rest.md diff --git a/.changeset/tidy-otters-rest.md b/.changeset/tidy-otters-rest.md new file mode 100644 index 000000000..0aed13cde --- /dev/null +++ b/.changeset/tidy-otters-rest.md @@ -0,0 +1,6 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fix image preview Blob URLs leaking when `useAttachment` unmounts after a successful `applyChanges()` call. +Previews remain available while displayed and are released on deletion or unmount. diff --git a/packages/core/src/components/attachment/use-attachment.test.ts b/packages/core/src/components/attachment/use-attachment.test.ts index d527b8391..fce10f6e6 100644 --- a/packages/core/src/components/attachment/use-attachment.test.ts +++ b/packages/core/src/components/attachment/use-attachment.test.ts @@ -6,7 +6,7 @@ import type { AttachmentItem, AttachmentOperation } from "./types"; afterEach(() => { cleanup(); - vi.clearAllMocks(); + vi.restoreAllMocks(); }); const existingItem: AttachmentItem = { @@ -63,6 +63,28 @@ describe("useAttachment", () => { createObjectUrlSpy.mockRestore(); }); + it.each([false, true])("revokes previews on unmount after applyChanges: %s", async (apply) => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview-url"); + const revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const { result, unmount } = renderHook(() => + useAttachment({ initialItems: [{ ...existingItem, previewUrl: "blob:external-preview" }] }), + ); + + act(() => { + result.current.props.onUpload?.([new File(["img"], "photo.jpg", { type: "image/jpeg" })]); + }); + if (apply) { + await act(async () => { + await result.current.applyChanges(async () => {}); + }); + } + + expect(result.current.props.items[0]?.previewUrl).toBe("blob:preview-url"); + expect(revokeObjectUrlSpy).not.toHaveBeenCalled(); + unmount(); + expect(revokeObjectUrlSpy.mock.calls).toEqual([["blob:preview-url"]]); + }); + it("removes item when onDelete is called for a pending upload", () => { const { result } = renderHook(() => useAttachment()); const file = new File(["hello"], "invoice.pdf", { @@ -85,7 +107,7 @@ describe("useAttachment", () => { vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview-url"); const revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); - const { result } = renderHook(() => useAttachment()); + const { result, unmount } = renderHook(() => useAttachment()); const file = new File(["img"], "photo.jpg", { type: "image/jpeg" }); act(() => { @@ -98,8 +120,8 @@ describe("useAttachment", () => { }); expect(revokeObjectUrlSpy).toHaveBeenCalledWith("blob:preview-url"); - - revokeObjectUrlSpy.mockRestore(); + unmount(); + expect(revokeObjectUrlSpy).toHaveBeenCalledTimes(1); }); it("applyChanges receives upload operation for new files", async () => { diff --git a/packages/core/src/components/attachment/use-attachment.ts b/packages/core/src/components/attachment/use-attachment.ts index b81fd8ff9..6cdec2cd5 100644 --- a/packages/core/src/components/attachment/use-attachment.ts +++ b/packages/core/src/components/attachment/use-attachment.ts @@ -26,8 +26,7 @@ type OperationEntry = AttachmentOperation & { readonly opKey: string }; // --------------------------------------------------------------------------- // Item lifecycle helpers -// These are the only two places that call URL.createObjectURL / revokeObjectURL. -// Keeping side effects here makes the rest of the hook free of URL management. +// Create previews here; release them on item deletion or hook unmount. // --------------------------------------------------------------------------- /** @@ -139,14 +138,14 @@ export function useAttachment(options: UseAttachmentOptions = {}): UseAttachment // Flush guard: prevents concurrent applyChanges calls from sending duplicate operations. const isApplyingRef = useRef(false); - // Used only by the useEffect cleanup on unmount to revoke any remaining blob URLs. - // A ref is needed here because the cleanup function captures the ref object (stable), - // not state.operations (which would be stale inside the effect's closure). - const operationsRef = useRef(state.operations); - operationsRef.current = state.operations; + // Previews outlive buffered upload operations after applyChanges succeeds. + const previewUrlsRef = useRef(new Set()); const onUpload = useCallback((files: File[]) => { const newItems = files.map(buildPendingItem); + for (const item of newItems) { + if (item.previewUrl?.startsWith("blob:")) previewUrlsRef.current.add(item.previewUrl); + } const newOps: OperationEntry[] = files.map((file, i) => ({ type: "upload", file, @@ -158,18 +157,15 @@ export function useAttachment(options: UseAttachmentOptions = {}): UseAttachment const onDelete = useCallback((item: AttachmentItem) => { releasePendingItem(item); + if (item.previewUrl) previewUrlsRef.current.delete(item.previewUrl); dispatch({ type: "DELETE", itemId: item.id }); }, []); - // Revoke all blob URLs for pending image uploads on unmount to prevent memory leaks. - // onDelete handles revocation for items removed during the component's lifetime; - // this cleanup covers any remaining pending uploads when the component is unmounted - // (e.g. the user navigates away without submitting). + // Revoke only URLs created by this hook that have not been deleted. useEffect(() => { + const previewUrls = previewUrlsRef.current; return () => { - for (const op of operationsRef.current) { - if (op.type === "upload") releasePendingItem(op.item); - } + for (const url of previewUrls) URL.revokeObjectURL(url); }; }, []);