Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tidy-otters-rest.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 26 additions & 4 deletions packages/core/src/components/attachment/use-attachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { AttachmentItem, AttachmentOperation } from "./types";

afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});

const existingItem: AttachmentItem = {
Expand Down Expand Up @@ -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", {
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand Down
24 changes: 10 additions & 14 deletions packages/core/src/components/attachment/use-attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// ---------------------------------------------------------------------------

/**
Expand Down Expand Up @@ -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<string>());

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,
Expand All @@ -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);
};
}, []);

Expand Down
Loading