Skip to content

Commit bc591c6

Browse files
committed
fix(web): revoke draft image blob urls on project deletion
1 parent 526ca72 commit bc591c6

2 files changed

Lines changed: 96 additions & 47 deletions

File tree

apps/web/src/composerDraftStore.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,58 @@ describe("composerDraftStore clearComposerContent", () => {
211211
});
212212
});
213213

214+
describe("composerDraftStore draft thread cleanup", () => {
215+
let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
216+
let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
217+
218+
beforeEach(() => {
219+
resetComposerDraftStore();
220+
originalRevokeObjectUrl = URL.revokeObjectURL;
221+
revokeSpy = vi.fn();
222+
URL.revokeObjectURL = revokeSpy;
223+
});
224+
225+
afterEach(() => {
226+
URL.revokeObjectURL = originalRevokeObjectUrl;
227+
});
228+
229+
it("revokes draft image blob URLs when clearing a project's draft thread", () => {
230+
const projectId = ProjectId.makeUnsafe("project-clear");
231+
const threadId = ThreadId.makeUnsafe("thread-clear-project");
232+
const image = makeImage({
233+
id: "img-project-clear",
234+
previewUrl: "blob:project-clear",
235+
});
236+
237+
useComposerDraftStore.getState().setProjectDraftThreadId(projectId, threadId);
238+
useComposerDraftStore.getState().addImage(threadId, image);
239+
240+
useComposerDraftStore.getState().clearProjectDraftThreadId(projectId);
241+
242+
expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
243+
expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
244+
expect(revokeSpy).toHaveBeenCalledWith("blob:project-clear");
245+
});
246+
247+
it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => {
248+
const projectId = ProjectId.makeUnsafe("project-clear-by-id");
249+
const threadId = ThreadId.makeUnsafe("thread-clear-by-id");
250+
const image = makeImage({
251+
id: "img-project-clear-by-id",
252+
previewUrl: "blob:project-clear-by-id",
253+
});
254+
255+
useComposerDraftStore.getState().setProjectDraftThreadId(projectId, threadId);
256+
useComposerDraftStore.getState().addImage(threadId, image);
257+
258+
useComposerDraftStore.getState().clearProjectDraftThreadById(projectId, threadId);
259+
260+
expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
261+
expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
262+
expect(revokeSpy).toHaveBeenCalledWith("blob:project-clear-by-id");
263+
});
264+
});
265+
214266
describe("composerDraftStore syncPersistedAttachments", () => {
215267
const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
216268

apps/web/src/composerDraftStore.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,47 @@ function revokeObjectPreviewUrl(previewUrl: string): void {
662662
URL.revokeObjectURL(previewUrl);
663663
}
664664

665+
function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void {
666+
if (!draft) {
667+
return;
668+
}
669+
for (const image of draft.images) {
670+
revokeObjectPreviewUrl(image.previewUrl);
671+
}
672+
}
673+
674+
type ComposerDraftStoreData = Pick<
675+
ComposerDraftStoreState,
676+
"draftsByThreadId" | "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
677+
>;
678+
679+
function removeDraftThreadState(
680+
state: ComposerDraftStoreData,
681+
threadId: ThreadId,
682+
nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>,
683+
): ComposerDraftStoreData {
684+
const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
685+
...state.draftThreadsByThreadId,
686+
};
687+
let nextDraftsByThreadId = state.draftsByThreadId;
688+
689+
if (!Object.values(nextProjectDraftThreadIdByProjectId).includes(threadId)) {
690+
delete nextDraftThreadsByThreadId[threadId];
691+
const existingDraft = state.draftsByThreadId[threadId];
692+
if (existingDraft !== undefined) {
693+
revokeDraftThreadPreviewUrls(existingDraft);
694+
nextDraftsByThreadId = { ...state.draftsByThreadId };
695+
delete nextDraftsByThreadId[threadId];
696+
}
697+
}
698+
699+
return {
700+
draftsByThreadId: nextDraftsByThreadId,
701+
draftThreadsByThreadId: nextDraftThreadsByThreadId,
702+
projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
703+
};
704+
}
705+
665706
function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
666707
if (!value || typeof value !== "object") {
667708
return null;
@@ -1429,22 +1470,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
14291470
const { [projectId]: _removed, ...restProjectMappingsRaw } =
14301471
state.projectDraftThreadIdByProjectId;
14311472
const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1432-
const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1433-
...state.draftThreadsByThreadId,
1434-
};
1435-
let nextDraftsByThreadId = state.draftsByThreadId;
1436-
if (!Object.values(restProjectMappings).includes(threadId)) {
1437-
delete nextDraftThreadsByThreadId[threadId];
1438-
if (state.draftsByThreadId[threadId] !== undefined) {
1439-
nextDraftsByThreadId = { ...state.draftsByThreadId };
1440-
delete nextDraftsByThreadId[threadId];
1441-
}
1442-
}
1443-
return {
1444-
draftsByThreadId: nextDraftsByThreadId,
1445-
draftThreadsByThreadId: nextDraftThreadsByThreadId,
1446-
projectDraftThreadIdByProjectId: restProjectMappings,
1447-
};
1473+
return removeDraftThreadState(state, threadId, restProjectMappings);
14481474
});
14491475
},
14501476
clearProjectDraftThreadById: (projectId, threadId) => {
@@ -1458,34 +1484,13 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
14581484
const { [projectId]: _removed, ...restProjectMappingsRaw } =
14591485
state.projectDraftThreadIdByProjectId;
14601486
const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1461-
const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1462-
...state.draftThreadsByThreadId,
1463-
};
1464-
let nextDraftsByThreadId = state.draftsByThreadId;
1465-
if (!Object.values(restProjectMappings).includes(threadId)) {
1466-
delete nextDraftThreadsByThreadId[threadId];
1467-
if (state.draftsByThreadId[threadId] !== undefined) {
1468-
nextDraftsByThreadId = { ...state.draftsByThreadId };
1469-
delete nextDraftsByThreadId[threadId];
1470-
}
1471-
}
1472-
return {
1473-
draftsByThreadId: nextDraftsByThreadId,
1474-
draftThreadsByThreadId: nextDraftThreadsByThreadId,
1475-
projectDraftThreadIdByProjectId: restProjectMappings,
1476-
};
1487+
return removeDraftThreadState(state, threadId, restProjectMappings);
14771488
});
14781489
},
14791490
clearDraftThread: (threadId) => {
14801491
if (threadId.length === 0) {
14811492
return;
14821493
}
1483-
const existing = get().draftsByThreadId[threadId];
1484-
if (existing) {
1485-
for (const image of existing.images) {
1486-
revokeObjectPreviewUrl(image.previewUrl);
1487-
}
1488-
}
14891494
set((state) => {
14901495
const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
14911496
const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
@@ -1500,15 +1505,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
15001505
([, draftThreadId]) => draftThreadId !== threadId,
15011506
),
15021507
) as Record<ProjectId, ThreadId>;
1503-
const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
1504-
state.draftThreadsByThreadId;
1505-
const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } =
1506-
state.draftsByThreadId;
1507-
return {
1508-
draftsByThreadId: restDraftsByThreadId,
1509-
draftThreadsByThreadId: restDraftThreadsByThreadId,
1510-
projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1511-
};
1508+
return removeDraftThreadState(state, threadId, nextProjectDraftThreadIdByProjectId);
15121509
});
15131510
},
15141511
setStickyModelSelection: (modelSelection) => {

0 commit comments

Comments
 (0)