fix: report real upload progress; XHR with progress events #333

The Files page UI sat at 0% throughout an upload because uploadBlob()
uses fetch(), which does not surface upload progress events. The store
set loaded=0 before the call and loaded=file.size after it, so users
saw the progress bar jump from 0% straight to 100% on completion --
and on slow connections (or large files) it appeared frozen.

Switch uploadBlob() to XHR when the caller passes onProgress or an
AbortSignal, so progress events from xhr.upload.onprogress can drive
the UI. Callers that don't pass either keep the fetch path so we
preserve the existing 401-retry behaviour in authenticatedFetch().

Wire the file store to pass both onProgress (updates uploadProgress
in real time) and the existing AbortController's signal (so cancel
now actually aborts the network request, not just the post-upload
createFileNode step).

uploadBlob() is part of IJMAPClient so the signature change is also
applied to the demo client (synthesises 0% then 100%).
This commit is contained in:
Shuki Vaknin
2026-05-28 18:20:22 +02:00
committed by GitHub
parent e86183b44a
commit e93dd44111
4 changed files with 121 additions and 16 deletions
+89 -12
View File
@@ -2788,7 +2788,71 @@ export class JMAPClient implements IJMAPClient {
}
}
async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> {
private xhrUpload(
url: string,
file: File,
onProgress?: (loaded: number, total: number) => void,
signal?: AbortSignal,
): Promise<string> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
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 = () => {
cleanup();
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.send(file);
});
}
// Signature accepts either the legacy positional accountId string OR
// the options bag introduced for progress / signal so existing
// call-sites keep compiling without touching every plugin.
async uploadBlob(
file: File,
optsOrAccountId?:
| string
| {
accountId?: string;
onProgress?: (loaded: number, total: number) => void;
signal?: AbortSignal;
},
): Promise<{ blobId: string; size: number; type: string }> {
const opts =
typeof optsOrAccountId === 'string'
? { accountId: optsOrAccountId }
: optsOrAccountId ?? {};
if (!this.session) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2798,20 +2862,33 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Upload URL not available');
}
const targetAccountId = accountId || this.accountId;
const targetAccountId = opts.accountId || this.accountId;
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
// XHR path: fetch() does not expose upload progress events, so when the
// caller wants progress (or an AbortSignal) we use XMLHttpRequest. The
// fetch path is kept for callers that don't need either, to preserve
// existing 401/retry behaviour through authenticatedFetch().
let responseText: string;
if (opts.onProgress || opts.signal) {
responseText = await this.xhrUpload(
finalUploadUrl,
file,
opts.onProgress,
opts.signal,
);
} else {
const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
}
responseText = await response.text();
}
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);