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:
@@ -494,9 +494,17 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
async uploadBlob(
|
||||
file: File,
|
||||
opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal },
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new DOMException('Upload aborted', 'AbortError');
|
||||
}
|
||||
opts?.onProgress?.(0, file.size);
|
||||
const blobId = generateDemoId('blob');
|
||||
this.blobStore.set(blobId, file);
|
||||
opts?.onProgress?.(file.size, file.size);
|
||||
return { blobId, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,16 @@ export interface IJMAPClient {
|
||||
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
|
||||
uploadBlob(
|
||||
file: File,
|
||||
optsOrAccountId?:
|
||||
| string
|
||||
| {
|
||||
accountId?: string;
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ blobId: string; size: number; type: string }>;
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||
|
||||
+89
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user