fix: cancel pending uploads when composer chips are removed

This commit is contained in:
2026-10-05 20:55:17 +09:00
parent 9812e68b4d
commit c847c2ffad
7 changed files with 186 additions and 34 deletions
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import type { FeatureInvocationDescriptor, Segment } from "#lib/generated/protocol.ts";
import { undo } from "@codemirror/commands";
import { redo, undo } from "@codemirror/commands";
import ComposerInput from "./ComposerInput.svelte";
afterEach(cleanup);
@@ -188,6 +188,52 @@ test("restored chip descriptor edits reject same-draft cursor ABA", async () =>
expect(ui.component.snapshot().segments).toEqual([invocationSegment]);
});
test.each(["Backspace", "Delete", "range", "cut", "replace"])("pending upload %s cancels its reservation before late completion and Undo", async (operation) => {
const oncancelupload = vi.fn();
const ui = render(ComposerInput, { historyScope: `upload-delete-${operation}`, oncancelupload });
const cm = editor(ui.container);
type(cm, "before after", 7);
const reservation = ui.component.reserveUpload("report.md")!;
const from = 7, to = cm.state.selection.main.head;
if (operation === "Backspace" || operation === "Delete") {
cm.dispatch({ selection: { anchor: operation === "Backspace" ? to : from } });
await fireEvent.keyDown(cm.contentDOM, { key: operation });
} else if (operation === "replace") {
cm.dispatch({ changes: { from, to, insert: "replacement" }, userEvent: "input" });
} else {
// happy-dom emits selectionchange synchronously while CodeMirror updates a
// focused DOM range. Exercise the transaction/cut handler unfocused here;
// the Chromium suite covers focused keyboard range deletion.
cm.contentDOM.blur();
cm.dispatch({ selection: { anchor: from, head: to } });
if (operation === "cut") await fireEvent.cut(cm.contentDOM, { clipboardData: { setData: vi.fn() } });
else await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
}
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
undo(cm);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
redo(cm);
ui.component.clear();
expect(oncancelupload).toHaveBeenCalledOnce();
});
test.each(["clear", "restore", "unmount"])("pending upload %s boundary cancels once without acquiring late staged resources", (boundary) => {
const oncancelupload = vi.fn();
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: `upload-boundary-${boundary}`, oncancelupload, onremoveatom });
const reservation = ui.component.reserveUpload("report.md")!;
const complete = ui.component.completeUpload;
if (boundary === "clear") ui.component.clear();
else if (boundary === "restore") ui.component.restoreSegments([invocationSegment]);
else ui.unmount();
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
expect(complete(reservation, uploadedSegment)).toBe(false);
expect(onremoveatom).not.toHaveBeenCalled();
});
test("cancelled upload cannot become sendable via late completion or Undo", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "upload-cancel-late", onremoveatom });
@@ -80,6 +80,7 @@
signal: AbortSignal,
) => Promise<ComposerCompletionEntry[]>;
onremoveatom?: (segment: Segment) => void;
oncancelupload?: (reservation: number) => void;
onclientadapter?: (descriptor: FeatureInvocationDescriptor) => void;
}
@@ -98,6 +99,7 @@
resolveFeatureCompletions,
resolveFeatureArgumentCompletions,
onremoveatom,
oncancelupload,
onclientadapter,
}: Props = $props();
@@ -105,6 +107,7 @@
let view: EditorView | null = null;
let editorExtensions: Extension[] = [];
const stagedTypedAtoms = new Map<number, Segment>();
const pendingUploads = new Set<number>();
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1;
@@ -411,9 +414,19 @@
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerTypedAtom));
if (!additions.length) return registry;
if (!additions.length && !transaction.docChanged) return registry;
const next = new Map(registry);
for (const addition of additions) next.set(addition.value.key, addition.value.atom);
if (transaction.docChanged) {
const live = new Set(composerTypedAtoms(transaction.newDoc.toString(), next).map((atom) => atom.key));
for (const atom of composerTypedAtoms(transaction.startState.doc.toString(), registry)) {
if (atom.uploadReservation && !live.has(atom.key)) {
// Cancellation is part of the editor transaction, not a later callback.
// Undo restores text only; it cannot regain this upload reservation.
next.set(atom.key, { segment: { kind: "text", content: "" }, label: "Removed upload" });
}
}
}
return next;
},
});
@@ -457,7 +470,14 @@
EditorView.decorations.of((currentView) => typedDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => typedDecorations(currentView.state)),
];
// Deleted uploads remain staged while Undo can still restore their atoms.
function releasePendingUploads(): void {
const cancelled = [...pendingUploads];
pendingUploads.clear();
for (const key of cancelled) oncancelupload?.(key);
}
// Completed uploads remain staged while Undo can still restore their atoms.
// Pending reservations are cancelled immediately, not retained as Undo leases.
// Release abandoned resources at a draft boundary; accepted live atoms belong to the host.
function releaseStagedAtoms(accepted = false): void {
const live = accepted && view ? new Set(composerTypedAtoms(
@@ -481,7 +501,7 @@
function insertTypedSegment(
segment: Segment,
options: { from?: number; to?: number; cleanup?: boolean; label?: string } = {},
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
): number | null {
if (!view || view.state.readOnly) return null;
const selection = view.state.selection.main;
@@ -489,13 +509,14 @@
const to = options.to ?? selection.to;
const key = nextTypedKey++;
if (options.cleanup) stagedTypedAtoms.set(key, segment);
if (options.uploadReservation) pendingUploads.add(key);
const token = composerTypedToken(key);
view.dispatch({
changes: { from, to, insert: token },
selection: EditorSelection.cursor(from + token.length),
effects: registerTypedAtom.of({
key,
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup },
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup, uploadReservation: options.uploadReservation },
}),
annotations: isolateHistory.of("full"),
userEvent: "input.complete",
@@ -784,6 +805,14 @@
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
// StateField tombstones deleted reservations before notifying the
// adapter. The callback must not dispatch a nested editor update.
for (const key of pendingUploads) {
if (!update.state.field(typedRegistry).get(key)?.uploadReservation) {
pendingUploads.delete(key);
oncancelupload?.(key);
}
}
if (update.docChanged || update.selectionSet) {
editorGeneration++;
// Invalidate synchronously: an A→B→A edit/cursor cycle in one
@@ -881,6 +910,7 @@
return () => {
closeCompletion();
releasePendingUploads();
releaseStagedAtoms();
editVersion++;
view?.destroy();
@@ -940,11 +970,14 @@
}
export function reserveUpload(fileName: string): number | null {
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}` });
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}`, uploadReservation: true });
}
export function completeUpload(key: number, segment: Segment): boolean {
if (!view || view.state.field(typedRegistry).get(key)?.segment.kind !== "unknown") return false;
if (!view || !pendingUploads.has(key) ||
!view.state.field(typedRegistry).get(key)?.uploadReservation ||
!composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry)).some((atom) => atom.key === key)) return false;
pendingUploads.delete(key);
stagedTypedAtoms.set(key, segment);
view.dispatch({ effects: registerTypedAtom.of({
key, atom: { segment, label: typedAtomLabel(segment), cleanup: true },
@@ -968,6 +1001,7 @@
export function clear(acceptedTypedResources = false, retainStagedResources = false): void {
if (!view) return;
releasePendingUploads();
if (!retainStagedResources) releaseStagedAtoms(acceptedTypedResources);
editVersion++;
closeCompletion();
@@ -32,6 +32,8 @@ export interface ComposerTypedSegment {
label: string;
/** Whether deleting this unsent atom must release client-staged state. */
cleanup?: boolean;
/** Client-only reservation whose removal cancels an in-progress upload. */
uploadReservation?: boolean;
}
export interface ComposerTypedAtom extends ComposerTypedSegment {
@@ -1091,6 +1091,17 @@
);
}
function cancelComposerUpload(reservation: number): void {
const attachment = attachments.find((candidate) =>
uploadReservations.get(candidate.id) === reservation);
if (!attachment) return;
// The editor already cancelled/tombstoned its reservation. Do not call
// cancelUpload here: this may run inside a CodeMirror update listener.
uploadReservations.delete(attachment.id);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
attachment.request?.abort();
}
function completeAttachmentUpload(
attachment: ComposerAttachment,
reference: NonNullable<ComposerAttachment["reference"]>,
@@ -2177,6 +2188,7 @@
resolveFeatureArgumentCompletions={(context, prefix, signal) =>
featureArgumentCompletionLane(context).request(prefix, signal)}
onremoveatom={(segment) => void releaseComposerAtom(segment)}
oncancelupload={cancelComposerUpload}
onclientadapter={(descriptor) => {
if (descriptor.client_adapter === "attachment") fileInput?.click();
}}