Skip to content

Commit 6c3dc9c

Browse files
committed
fix(web): revoke draft image blob urls on project deletion
1 parent 3079a3d commit 6c3dc9c

3 files changed

Lines changed: 59 additions & 7 deletions

File tree

apps/web/src/composerDraftStore.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => {
561561
expect(draftByKey(draftId)).toBeUndefined();
562562
});
563563

564+
it("revokes draft image blob URLs when clearing a project's draft thread", () => {
565+
const store = useComposerDraftStore.getState();
566+
const originalRevokeObjectUrl = URL.revokeObjectURL;
567+
const revokeSpy = vi.fn<(url: string) => void>();
568+
URL.revokeObjectURL = revokeSpy;
569+
570+
try {
571+
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
572+
store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" }));
573+
574+
store.clearProjectDraftThreadId(projectRef);
575+
576+
expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
577+
expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
578+
expect(revokeSpy).toHaveBeenCalledWith("blob:clear");
579+
} finally {
580+
URL.revokeObjectURL = originalRevokeObjectUrl;
581+
}
582+
});
583+
584+
it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => {
585+
const store = useComposerDraftStore.getState();
586+
const originalRevokeObjectUrl = URL.revokeObjectURL;
587+
const revokeSpy = vi.fn<(url: string) => void>();
588+
URL.revokeObjectURL = revokeSpy;
589+
590+
try {
591+
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
592+
store.addImage(
593+
draftId,
594+
makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }),
595+
);
596+
597+
store.clearProjectDraftThreadById(projectRef, draftId);
598+
599+
expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
600+
expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
601+
expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id");
602+
} finally {
603+
URL.revokeObjectURL = originalRevokeObjectUrl;
604+
}
605+
});
606+
564607
it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
565608
const store = useComposerDraftStore.getState();
566609
store.setProjectDraftThreadId(projectRef, draftId, { threadId });

apps/web/src/composerDraftStore.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void {
773773
URL.revokeObjectURL(previewUrl);
774774
}
775775

776+
function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void {
777+
if (!draft) {
778+
return;
779+
}
780+
for (const image of draft.images) {
781+
revokeObjectPreviewUrl(image.previewUrl);
782+
}
783+
}
784+
776785
function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
777786
if (!value || typeof value !== "object") {
778787
return null;
@@ -1092,7 +1101,8 @@ function removeDraftThreadReferences(
10921101
) as Record<string, string>;
10931102
const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =
10941103
state.draftThreadsByThreadKey;
1095-
const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1104+
const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1105+
revokeDraftThreadPreviewUrls(removedComposerDraft);
10961106
return {
10971107
draftsByThreadKey: restDraftsByThreadKey,
10981108
draftThreadsByThreadKey: restDraftThreadsByThreadKey,
@@ -2054,12 +2064,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
20542064
if (threadKey.length === 0) {
20552065
return;
20562066
}
2057-
const existing = get().draftsByThreadKey[threadKey];
2058-
if (existing) {
2059-
for (const image of existing.images) {
2060-
revokeObjectPreviewUrl(image.previewUrl);
2061-
}
2062-
}
20632067
set((state) => {
20642068
const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
20652069
const hasLogicalProjectMapping = Object.values(

apps/web/src/environments/runtime/service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,11 @@ function applyRecoveredEventBatch(
263263
.getState()
264264
.clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
265265
}
266+
for (const event of events) {
267+
if (event.type === "project.deleted") {
268+
draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId));
269+
}
270+
}
266271
for (const threadId of batchEffects.removeTerminalStateThreadIds) {
267272
useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId));
268273
}

0 commit comments

Comments
 (0)