Merge pull request #1464 from VasilisNtovolos/fix/xhr-upload-inactivity-timeout

fix: time out stalled XHR uploads after inactivity
This commit is contained in:
Linus Rath
2026-10-31 16:24:56 +01:00
4 changed files with 649 additions and 21 deletions
@@ -0,0 +1,326 @@
import { render, screen, within, fireEvent, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
// Keep the real composer queue and JMAP client; stub unrelated UI and stores.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
// The selected identity is namespaced with the local account that owns it.
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => {
const idx = id.indexOf('::');
return idx === -1
? { localAccountId: null, rawId: id }
: { localAccountId: id.slice(0, idx), rawId: id.slice(idx + 2) };
},
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'acct-2::id-other', email: 'other@example.com', name: 'Other' }],
defaultIdentityId: 'acct-2::id-other',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
onBeforeAttachmentUpload: { intercept: async () => true },
onBeforeBlobUpload: { transform: async (fileId: unknown) => fileId },
onAfterAttachmentUpload: { emit: vi.fn() },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
isExternalAttachmentResult: () => false,
}));
const { deleteStagedFile } = vi.hoisted(() => ({
deleteStagedFile: vi.fn(async (_id: string) => {}),
}));
vi.mock('@/lib/plugin-storage', () => ({
fileStorage: {
saveFile: async () => {},
getFile: async () => null,
deleteFile: deleteStagedFile,
},
}));
const progressCleanup = vi.hoisted(() => vi.fn());
vi.mock('@/lib/upload-progress', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/upload-progress')>();
return { ...original, onUploadProgress: (id: string, listener: (loaded: number, total: number) => void) => {
const unsubscribe = original.onUploadProgress(id, listener);
return () => { unsubscribe(); progressCleanup(); };
} };
});
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
plainTextBodyHasSignature: () => false,
plainTextBodyWithoutSignature: (body: string) => body,
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
import { RequestTimeoutError } from '@/lib/jmap/client';
import { UploadXHR, uploadClient } from '@/lib/__tests__/helpers/upload-xhr';
import { toast } from '@/stores/toast-store';
import { emailHooks } from '@/lib/plugin-hooks';
const INACTIVITY_MS = 300_000;
const file = (name: string) => new File(['0123456789'], name, { type: 'text/plain' });
async function advance(ms = 0) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
}
async function select(input: HTMLInputElement, files: File[]) {
await act(async () => {
fireEvent.change(input, { target: { files } });
await vi.advanceTimersByTimeAsync(0);
});
}
function chip(name: string) {
const progress = screen.queryByRole('progressbar', { name });
// Each attachment chip contains its label and cancel/remove button.
return progress?.parentElement ?? screen.getByText(name).parentElement!.parentElement!.parentElement!;
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
UploadXHR.instances = [];
vi.stubGlobal('XMLHttpRequest', UploadXHR);
vi.stubGlobal('fetch', vi.fn(() => { throw new Error('Unexpected networking'); }));
});
afterEach(async () => {
cleanup();
for (const xhr of UploadXHR.instances) {
xhr.abort();
// Drain any queued requests before restoring the synthetic transport.
await vi.advanceTimersByTimeAsync(0);
}
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
useAuthStore.setState({ client: null });
});
describe('composer XHR inactivity recovery', () => {
it.each([0, 10])('releases stalled A after %i uploaded bytes, starts B, and permits removing/re-adding A', async loaded => {
const client = uploadClient();
const upload = vi.spyOn(client, 'uploadBlob');
useAuthStore.setState({ client: client as never });
render(<EmailComposer
initialData={{ to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false,
selectedIdentityId: 'acct-2::id-other', subAddressTag: '', mode: 'compose', draftId: null }}
onClose={vi.fn()}
/>);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const a = file('A.txt');
await select(input, [a, file('B.txt')]);
expect(upload).toHaveBeenCalledTimes(1);
const aXHR = UploadXHR.instances[0];
if (loaded) {
await advance(120_000);
act(() => aXHR.progress(loaded));
}
const aOutcome = upload.mock.results[0].value as ReturnType<typeof client.uploadBlob>;
// Attach the rejection assertion before the fake clock expires the request.
const result = aOutcome.catch((error: unknown) => error);
await advance(INACTIVITY_MS - 1);
expect(screen.getByRole('progressbar', { name: 'A.txt' })).toHaveAttribute('aria-valuenow', String(loaded * 10));
expect(screen.getByRole('progressbar', { name: 'B.txt' })).not.toHaveAttribute('aria-valuenow');
expect(toast.error).not.toHaveBeenCalled();
expect(deleteStagedFile).not.toHaveBeenCalled();
await advance(1);
// Timeout must let the existing serial loop reach B.
expect(upload).toHaveBeenCalledTimes(2);
expect(await result).toBeInstanceOf(RequestTimeoutError);
expect(aXHR.abort).toHaveBeenCalledOnce();
expect(within(chip('A.txt')).getByText('attachment_upload_failed')).toBeTruthy();
expect(within(chip('A.txt')).queryByRole('progressbar')).toBeNull();
expect(screen.getByRole('progressbar', { name: 'B.txt' })).toHaveAttribute('aria-valuenow', '0');
expect(toast.error).toHaveBeenCalledExactlyOnceWith('upload_failed');
expect(deleteStagedFile).toHaveBeenCalledTimes(1);
expect(progressCleanup).toHaveBeenCalledTimes(1);
act(() => UploadXHR.instances[1].load());
await advance();
expect(screen.queryByRole('progressbar')).toBeNull();
expect(input.value).toBe('');
expect(progressCleanup).toHaveBeenCalledTimes(2);
fireEvent.click(within(chip('A.txt')).getByRole('button', { name: 'recipient_remove A.txt' }));
expect(screen.queryByText('A.txt')).toBeNull();
await select(input, [a]);
expect(upload).toHaveBeenCalledTimes(3);
expect(upload.mock.calls[2][0]).toBe(a);
act(() => UploadXHR.instances[2].load());
await advance();
expect(screen.queryByRole('progressbar')).toBeNull();
expect(screen.queryByText('attachment_upload_failed')).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
expect(deleteStagedFile).toHaveBeenCalledTimes(3);
expect(progressCleanup).toHaveBeenCalledTimes(3);
expect(emailHooks.onAfterAttachmentUpload.emit).toHaveBeenCalledTimes(2);
expect(UploadXHR.instances).toHaveLength(3);
expect(fetch).not.toHaveBeenCalled();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { vi } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
// No native networking: tests explicitly deliver progress and terminal events.
export class UploadXHR {
static instances: UploadXHR[] = [];
method = '';
url = '';
headers: Record<string, string> = {};
responseType = '';
responseText = '';
status = 0;
body: File | null = null;
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
ontimeout: (() => void) | null = null;
private done = false;
constructor() { UploadXHR.instances.push(this); }
open(method: string, url: string) { this.method = method; this.url = url; }
setRequestHeader(name: string, value: string) { this.headers[name] = value; }
send(body: File) { this.body = body; }
abort = vi.fn(() => {
// Native abort emits no abort event before send or after DONE.
if (this.body && !this.done) { this.done = true; this.onabort?.(); }
});
progress(loaded: number, total = this.body?.size ?? 0, lengthComputable = true) {
this.upload.onprogress?.(new ProgressEvent('progress', { loaded, total, lengthComputable }));
}
load(status = 201, text = JSON.stringify({ blobId: `blob-${this.body?.name}`, size: this.body?.size, type: this.body?.type })) {
this.done = true;
this.status = status;
this.responseText = text;
this.onload?.();
}
error() { this.done = true; this.onerror?.(); }
timeout() { this.done = true; this.ontimeout?.(); }
}
export function uploadClient() {
const client = new JMAPClient('https://upload.invalid', 'synthetic', 'synthetic');
// Seed only a synthetic connected session; avoid keep-alive/reconnect traffic.
Object.assign(client, {
accountId: 'synthetic',
session: {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { synthetic: { accountCapabilities: {} } },
primaryAccounts: {},
uploadUrl: 'https://upload.invalid/upload/{accountId}',
},
});
return client;
}
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RequestTimeoutError } from '../jmap/client';
import { UploadXHR, uploadClient } from './helpers/upload-xhr';
const INACTIVITY_MS = 300_000;
const file = () => new File(['0123456789'], 'A.txt', { type: 'text/plain' });
async function start(options: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; accountId?: string } = { onProgress: vi.fn() }) {
const client = uploadClient();
const body = file();
const completed = vi.fn();
const promise = client.uploadBlob(body, options);
void promise.then(completed, completed);
await vi.advanceTimersByTimeAsync(0);
return { promise, completed, body, xhr: UploadXHR.instances[0] };
}
beforeEach(() => {
vi.useFakeTimers();
UploadXHR.instances = [];
vi.stubGlobal('XMLHttpRequest', UploadXHR);
vi.stubGlobal('fetch', vi.fn(() => { throw new Error('Unexpected networking'); }));
});
afterEach(() => {
for (const xhr of UploadXHR.instances) xhr.abort();
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('JMAP XHR upload inactivity recovery', () => {
it('rejects a silent upload at five minutes, aborts it and does not retry', async () => {
const { promise, completed, xhr } = await start();
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
expect(completed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
await expect(promise).rejects.toThrow('Request timed out after 300s');
expect(xhr.abort).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(INACTIVITY_MS);
expect(UploadXHR.instances).toHaveLength(1);
expect(fetch).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('resets the threshold when additional bytes have transferred', async () => {
const { completed, xhr } = await start();
await vi.advanceTimersByTimeAsync(240_000);
xhr.progress(1);
await vi.advanceTimersByTimeAsync(240_000);
expect(completed).not.toHaveBeenCalled();
xhr.progress(2);
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
expect(completed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
});
it('does not renew for zero, duplicate or decreasing byte counts, or a changed total', async () => {
const { completed, xhr } = await start();
await vi.advanceTimersByTimeAsync(100_000);
xhr.progress(0);
await vi.advanceTimersByTimeAsync(100_000);
xhr.progress(2);
await vi.advanceTimersByTimeAsync(100_000);
xhr.progress(2, 20);
await vi.advanceTimersByTimeAsync(100_000);
xhr.progress(1);
await vi.advanceTimersByTimeAsync(99_999);
expect(completed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
});
it('allows a successful sixteen-minute transfer while bytes keep increasing', async () => {
const progress = vi.fn();
const { promise, completed, xhr, body } = await start({ onProgress: progress });
expect(progress).toHaveBeenCalledExactlyOnceWith(0, body.size);
for (let loaded = 1; loaded <= 4; loaded++) {
await vi.advanceTimersByTimeAsync(240_000);
xhr.progress(loaded);
expect(completed).not.toHaveBeenCalled();
}
xhr.load();
await expect(promise).resolves.toEqual({ blobId: 'blob-A.txt', size: 10, type: 'text/plain' });
expect(progress).toHaveBeenCalledTimes(5);
expect(xhr.abort).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it.each([5, 10])('expires after progress to %i bytes followed by five silent minutes', async loaded => {
const { completed, xhr } = await start();
await vi.advanceTimersByTimeAsync(120_000);
xhr.progress(loaded);
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
expect(completed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
expect(xhr.abort).toHaveBeenCalledOnce();
});
it('observes byte activity for signal-only callers and keeps the file-size progress fallback', async () => {
const controller = new AbortController();
const { promise, completed, xhr } = await start({ signal: controller.signal });
await vi.advanceTimersByTimeAsync(240_000);
xhr.progress(2, 0, false);
await vi.advanceTimersByTimeAsync(240_000);
expect(completed).not.toHaveBeenCalled();
xhr.load();
await promise;
const progress = vi.fn();
const next = uploadClient().uploadBlob(file(), { onProgress: progress });
await vi.advanceTimersByTimeAsync(0);
UploadXHR.instances[1].progress(3, 0, false);
expect(progress).toHaveBeenLastCalledWith(3, 10);
UploadXHR.instances[1].load();
await next;
});
it.each(['success', 'http', 'network', 'cancel', 'native abort', 'native timeout', 'invalid JSON'])('cleans the watchdog and signal listener after %s', async terminal => {
const controller = new AbortController();
const add = vi.spyOn(controller.signal, 'addEventListener');
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const { promise, completed, xhr } = await start({ signal: controller.signal });
await vi.advanceTimersByTimeAsync(120_000);
if (terminal === 'success') xhr.load();
else if (terminal === 'http') xhr.load(503, 'synthetic failure');
else if (terminal === 'network') xhr.error();
else if (terminal === 'cancel') controller.abort();
else if (terminal === 'native abort') xhr.abort();
else if (terminal === 'native timeout') xhr.timeout();
else xhr.load(201, 'invalid JSON');
await vi.advanceTimersByTimeAsync(0);
expect(completed).toHaveBeenCalledOnce();
if (terminal === 'success') await expect(promise).resolves.toHaveProperty('blobId');
else if (terminal === 'cancel' || terminal === 'native abort') await expect(promise).rejects.toHaveProperty('name', 'AbortError');
else if (terminal === 'native timeout') await expect(promise).rejects.toBeInstanceOf(RequestTimeoutError);
else await expect(promise).rejects.toBeInstanceOf(Error);
expect(remove).toHaveBeenCalledExactlyOnceWith('abort', add.mock.calls[0][1]);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(INACTIVITY_MS * 2);
expect(completed).toHaveBeenCalledOnce();
});
it.each(['timeout', 'cancel', 'load', 'error'])('%s wins the terminal race; retained late callbacks cannot renew or report', async first => {
const controller = new AbortController();
const progress = vi.fn();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const { completed, xhr } = await start({ signal: controller.signal, onProgress: progress });
// Retain callbacks even if cleanup detaches them from the XHR.
const late = { progress: xhr.upload.onprogress, load: xhr.onload, error: xhr.onerror, abort: xhr.onabort, timeout: xhr.ontimeout };
await vi.advanceTimersByTimeAsync(INACTIVITY_MS - 1);
if (first === 'cancel') controller.abort();
else if (first === 'load') xhr.load();
else if (first === 'error') xhr.error();
await vi.advanceTimersByTimeAsync(1);
controller.abort();
if (first === 'timeout') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.any(RequestTimeoutError));
else if (first === 'cancel') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'AbortError' }));
else if (first === 'load') expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ blobId: 'blob-A.txt' }));
else expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'Upload network error' }));
const progressCalls = progress.mock.calls.length;
late.progress?.(new ProgressEvent('progress', { loaded: 9, total: 10, lengthComputable: true }));
late.load?.(); late.error?.(); late.abort?.(); late.timeout?.();
await vi.advanceTimersByTimeAsync(INACTIVITY_MS * 2);
expect(completed).toHaveBeenCalledOnce();
expect(progress).toHaveBeenCalledTimes(progressCalls);
expect(remove).toHaveBeenCalledOnce();
expect(xhr.abort).toHaveBeenCalledTimes(first === 'timeout' || first === 'cancel' ? 1 : 0);
expect(vi.getTimerCount()).toBe(0);
});
it('rejects a pre-aborted signal without starting a request or watchdog', async () => {
const controller = new AbortController();
controller.abort();
await expect(uploadClient().uploadBlob(file(), { signal: controller.signal })).rejects.toHaveProperty('name', 'AbortError');
expect(UploadXHR.instances).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('handles cancellation from the initial progress callback before send', async () => {
const controller = new AbortController();
const { promise, completed, xhr } = await start({ signal: controller.signal, onProgress: () => controller.abort() });
expect(completed).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'AbortError' }));
await expect(promise).rejects.toHaveProperty('name', 'AbortError');
expect(xhr.body).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('cleans up a synchronous send failure', async () => {
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
vi.spyOn(UploadXHR.prototype, 'send').mockImplementationOnce(() => { throw new Error('synthetic send failure'); });
const { promise } = await start({ signal: controller.signal });
await expect(promise).rejects.toThrow('synthetic send failure');
expect(remove).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('preserves request construction and nested upload results', async () => {
const { promise, xhr, body } = await start({ accountId: 'group/a b', onProgress: vi.fn() });
expect(xhr.method).toBe('POST');
expect(xhr.url).toBe('https://upload.invalid/upload/group%2Fa%20b');
expect(xhr.headers).toEqual({ 'Content-Type': 'text/plain', Authorization: uploadClient().getAuthHeader() });
expect(xhr.body).toBe(body);
expect(xhr.responseType).toBe('text');
xhr.load(200, JSON.stringify({ 'group/a b': { blobId: 'nested', size: 10, type: 'text/plain' } }));
await expect(promise).resolves.toEqual({ blobId: 'nested', size: 10, type: 'text/plain' });
});
});
+55 -21
View File
@@ -5806,34 +5806,68 @@ export class JMAPClient implements IJMAPClient {
xhr.setRequestHeader('Authorization', this.authHeader);
xhr.responseType = 'text';
const onAbort = () => xhr.abort();
if (signal) signal.addEventListener('abort', onAbort, { once: true });
const cleanup = () => signal?.removeEventListener('abort', onAbort);
if (onProgress) {
// Fire 0% immediately so the UI leaves its initial state even
// before the first network packet flushes.
onProgress(0, file.size);
xhr.upload.onprogress = (ev) => {
// ev.total is only meaningful when lengthComputable; fall back
// to file.size so callers always get a usable denominator.
const total = ev.lengthComputable ? ev.total : file.size;
onProgress(ev.loaded, total);
};
}
xhr.onload = () => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let uploaded = 0;
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null;
xhr.upload.onprogress = null;
};
const settle = (complete: () => void): boolean => {
if (settled) return false;
settled = true;
cleanup();
complete();
return true;
};
const abortWith = (error: Error) => {
// Settle first: abort() may synchronously dispatch an abort event.
if (settle(() => reject(error))) xhr.abort();
};
const onAbort = () => abortWith(new DOMException('Upload aborted', 'AbortError'));
const onTimeout = () => abortWith(new RequestTimeoutError(JMAPClient.TRANSFER_TIMEOUT_MS));
const resetWatchdog = () => {
clearTimeout(timer);
// XHR uses this budget for inactivity, unlike fetch's deadline until
// response headers. Additional upload bytes renew it; after 100% it
// stays armed while waiting for the terminal response.
timer = setTimeout(onTimeout, JMAPClient.TRANSFER_TIMEOUT_MS);
};
xhr.upload.onprogress = (ev) => {
if (settled) return;
if (ev.loaded > uploaded) {
uploaded = ev.loaded;
resetWatchdog();
}
// ev.total is only meaningful when lengthComputable; fall back
// to file.size so callers always get a usable denominator.
onProgress?.(ev.loaded, ev.lengthComputable ? ev.total : file.size);
};
xhr.onload = () => settle(() => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`));
}
};
xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); };
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); };
});
xhr.onerror = () => settle(() => reject(new Error('Upload network error')));
xhr.onabort = () => settle(() => reject(new DOMException('Upload aborted', 'AbortError')));
xhr.ontimeout = onTimeout;
xhr.send(file);
try {
signal?.addEventListener('abort', onAbort, { once: true });
// Fire 0% immediately so the UI leaves its initial state even
// before the first network packet flushes.
onProgress?.(0, file.size);
if (settled) return;
resetWatchdog();
xhr.send(file);
} catch (error) {
settle(() => reject(error));
}
});
}