diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..4aa6d63412 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -836,6 +836,18 @@ ProfileLoaderAnimation--loading-view-not-found = View not found ProfileRootMessage--title = { -profiler-brand-name } ProfileRootMessage--additional = Back to home +# This string is used as the accessible label for the download progress bar. +ProfileRootMessage--download-progress-label = + .aria-label = Download progress +# This string is displayed when the total download size is known. +# Variables: +# $receivedSize (String) - Amount of data received so far, e.g. "3.2 MB" +# $totalSize (String) - Total download size, e.g. "14.5 MB" +ProfileRootMessage--download-progress-known = { $receivedSize } / { $totalSize } +# This string is displayed when the total download size is unknown. +# Variables: +# $receivedSize (String) - Amount of data received so far, e.g. "3.2 MB" +ProfileRootMessage--download-progress-unknown = { $receivedSize } downloaded ## Root diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index faa49d33d4..c2f36d6cdb 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -1008,9 +1008,43 @@ class SafariLocalhostHTTPLoadError extends Error { override name = 'SafariLocalhostHTTPLoadError'; } +type DownloadProgress = { + receivedBytes: number; + totalBytes: number | null; +}; + +/** + * Create a callback that aggregates download progress across multiple parallel + * fetches and dispatches the combined totals. Used for compare-mode downloads. + */ +function _makeAggregatedProgressDispatcher( + dispatch: Dispatch +): (key: string, progress: DownloadProgress) => void { + const progressByKey = new Map(); + return (key: string, progress: DownloadProgress) => { + progressByKey.set(key, progress); + let receivedBytes = 0; + let totalBytes: number | null = 0; + for (const p of progressByKey.values()) { + receivedBytes += p.receivedBytes; + if (totalBytes !== null && p.totalBytes !== null) { + totalBytes += p.totalBytes; + } else { + totalBytes = null; + } + } + dispatch({ + type: 'PROFILE_DOWNLOAD_PROGRESS', + receivedBytes, + totalBytes, + }); + }; +} + type FetchProfileArgs = { url: string; onTemporaryError: (param: TemporaryError) => void; + onDownloadProgress?: (progress: DownloadProgress) => void; // Allow tests to capture the reported error, but normally use console.error. reportError?: (...data: Array) => void; }; @@ -1019,6 +1053,85 @@ type ProfileOrZip = | { responseType: 'PROFILE'; profile: unknown } | { responseType: 'ZIP'; zip: JSZip }; +/** + * Read the full response body as an ArrayBuffer, reporting download progress + * via the onProgress callback. If the response body is not streamable (e.g. in + * older browsers or test environments), falls back to response.arrayBuffer(). + */ +async function _readResponseWithProgress( + response: Response, + onProgress?: (progress: DownloadProgress) => void +): Promise { + if (!onProgress || !response.body) { + return response.arrayBuffer(); + } + + const PROGRESS_THROTTLE_MS = 100; + const contentLength = response.headers.get('Content-Length'); + const totalBytes = contentLength ? parseInt(contentLength, 10) : null; + const reader = response.body.getReader(); + + const chunks: Uint8Array[] = []; + let lastProgressTime = 0; + let lastReportedBytes = -1; + let receivedBytes = 0; + + // When Content-Length is known and trustworthy, pre-allocate a single buffer + // and write directly into it to avoid a 2x memory spike. If the server sends + // more data than declared, fall back to chunk accumulation. + let preAllocated: Uint8Array | null = + totalBytes && totalBytes > 0 ? new Uint8Array(totalBytes) : null; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (preAllocated) { + if (receivedBytes + value.length <= totalBytes!) { + preAllocated.set(value, receivedBytes); + } else { + // Content-Length was wrong; abandon pre-allocated buffer and switch + // to chunk accumulation for the rest of the download. + chunks.push(preAllocated.slice(0, receivedBytes)); + chunks.push(value); + preAllocated = null; + } + } else { + chunks.push(value); + } + + receivedBytes += value.length; + + // Throttle progress callbacks to avoid excessive Redux dispatches. + const now = performance.now(); + if (now - lastProgressTime >= PROGRESS_THROTTLE_MS) { + onProgress({ receivedBytes, totalBytes }); + lastProgressTime = now; + lastReportedBytes = receivedBytes; + } + } + + // Report final progress so the bar reaches 100%, unless we just reported it. + if (lastReportedBytes !== receivedBytes) { + onProgress({ receivedBytes, totalBytes }); + } + + if (preAllocated) { + return preAllocated.buffer as ArrayBuffer; + } + + // Concatenate accumulated chunks. + const result = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result.buffer as ArrayBuffer; +} + /** * Tries to fetch a profile on `url`. If the profile is not found, * `onTemporaryError` is called with an appropriate error, we wait 1 second, and @@ -1032,7 +1145,7 @@ export async function _fetchProfile( ): Promise { const MAX_WAIT_SECONDS = 10; let i = 0; - const { url, onTemporaryError } = args; + const { url, onTemporaryError, onDownloadProgress } = args; // Allow tests to capture the reported error, but normally use console.error. const reportError = args.reportError || console.error; @@ -1050,7 +1163,11 @@ export async function _fetchProfile( // Case 2: successful answer. if (response.ok) { - return _extractProfileOrZipFromResponse(url, response, reportError); + const buffer = await _readResponseWithProgress( + response, + onDownloadProgress + ); + return _extractProfileOrZipFromBuffer(url, buffer, response, reportError); } // case 3: unrecoverable error. @@ -1108,10 +1225,11 @@ function _deduceContentType( /** * This function guesses the correct content-type (even if one isn't sent) and then - * attempts to use the proper method to extract the response. + * attempts to use the proper method to extract the profile or zip from a pre-read buffer. */ -async function _extractProfileOrZipFromResponse( +async function _extractProfileOrZipFromBuffer( url: string, + buffer: ArrayBuffer, response: Response, reportError: (...data: Array) => void ): Promise { @@ -1123,7 +1241,7 @@ async function _extractProfileOrZipFromResponse( case 'application/zip': return { responseType: 'ZIP', - zip: await _extractZipFromResponse(response, reportError), + zip: await _extractZipFromBuffer(buffer, response, reportError), }; case 'application/json': case null: @@ -1131,7 +1249,8 @@ async function _extractProfileOrZipFromResponse( // and try to process it as a profile. return { responseType: 'PROFILE', - profile: await _extractJsonFromResponse( + profile: await _extractJsonFromBuffer( + buffer, response, reportError, contentType @@ -1143,14 +1262,14 @@ async function _extractProfileOrZipFromResponse( } /** - * Attempt to load a zip file from a third party. This process can fail, so make sure - * to handle and report the error if it does. + * Attempt to load a zip file from a pre-read buffer. This process can fail, so make + * sure to handle and report the error if it does. */ -async function _extractZipFromResponse( +async function _extractZipFromBuffer( + buffer: ArrayBuffer, response: Response, reportError: (...data: Array) => void ): Promise { - const buffer = await response.arrayBuffer(); // Workaround for https://github.com/Stuk/jszip/issues/941 // When running this code in tests, `buffer` doesn't inherits from _this_ // realm's ArrayBuffer object, and this breaks JSZip which doesn't account for @@ -1190,18 +1309,16 @@ async function _extractJsonFromArrayBuffer( } /** - * Don't trust third party responses, try and handle a variety of responses gracefully. + * Don't trust third party data, try and handle a variety of inputs gracefully. */ -async function _extractJsonFromResponse( +async function _extractJsonFromBuffer( + buffer: ArrayBuffer, response: Response, reportError: (...data: Array) => void, fileType: 'application/json' | null ): Promise { - let arrayBuffer: ArrayBuffer | null = null; try { - // await before returning so that we can catch JSON parse errors. - arrayBuffer = await response.arrayBuffer(); - return await _extractJsonFromArrayBuffer(arrayBuffer); + return await _extractJsonFromArrayBuffer(buffer); } catch (error) { // Change the error message depending on the circumstance: let message; @@ -1209,10 +1326,10 @@ async function _extractJsonFromResponse( message = 'The network request to load the profile was aborted.'; } else if (fileType === 'application/json') { message = 'The profile’s JSON could not be decoded.'; - } else if (fileType === null && arrayBuffer !== null) { + } else if (fileType === null) { // If the content type is not specified, use a raw array buffer // to fallback to other supported profile formats. - return arrayBuffer; + return buffer; } else { message = oneLine` The profile could not be downloaded and decoded. This does not look like a supported file @@ -1267,6 +1384,13 @@ export function retrieveProfileOrZipFromUrl( onTemporaryError: (e: TemporaryError) => { dispatch(temporaryError(e)); }, + onDownloadProgress: (progress: DownloadProgress) => { + dispatch({ + type: 'PROFILE_DOWNLOAD_PROGRESS', + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + }); + }, }); switch (response.responseType) { @@ -1434,6 +1558,8 @@ export function retrieveProfilesToCompare( dispatch(waitingForProfileFromUrl()); try { + const reportAggregatedProgress = + _makeAggregatedProgressDispatcher(dispatch); const profilesAndStates = await Promise.all( profileViewUrls.map(async (url) => { if ( @@ -1451,6 +1577,9 @@ export function retrieveProfilesToCompare( onTemporaryError: (e: TemporaryError) => { dispatch(temporaryError(e)); }, + onDownloadProgress: (progress: DownloadProgress) => { + reportAggregatedProgress(profileUrl, progress); + }, }); if (response.responseType !== 'PROFILE') { throw new Error('Expected to receive a profile from _fetchProfile'); diff --git a/src/components/app/ProfileLoaderAnimation.tsx b/src/components/app/ProfileLoaderAnimation.tsx index ae6c8e980c..122b3bf1a2 100644 --- a/src/components/app/ProfileLoaderAnimation.tsx +++ b/src/components/app/ProfileLoaderAnimation.tsx @@ -65,12 +65,16 @@ class ProfileLoaderAnimationImpl extends PureComponent }}> {`Untranslated ${message}`} ); diff --git a/src/components/app/ProfileRootMessage.css b/src/components/app/ProfileRootMessage.css index 3341e30bf3..4989bceac6 100644 --- a/src/components/app/ProfileRootMessage.css +++ b/src/components/app/ProfileRootMessage.css @@ -43,6 +43,56 @@ font-size: 12px; } +.downloadProgress { + margin-top: 16px; +} + +.downloadProgressBarTrack { + overflow: hidden; + height: 8px; + border-radius: 4px; + background-color: var(--grey-30); +} + +.downloadProgressBarFill { + height: 100%; + border-radius: 4px; + background-color: var(--blue-60); + transition: width 150ms ease-out; +} + +.downloadProgressBarFillIndeterminate { + width: 30%; + height: 100%; + border-radius: 4px; + animation: indeterminateProgress 1.5s ease-in-out infinite; + background-color: var(--blue-60); +} + +@keyframes indeterminateProgress { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(433%); + } +} + +@media (prefers-reduced-motion: reduce) { + .downloadProgressBarFillIndeterminate { + width: 100%; + animation: none; + opacity: 0.5; + } +} + +.downloadProgressText { + margin-top: 4px; + color: var(--grey-50); + font-size: 12px; +} + .loading { position: relative; height: 40px; diff --git a/src/components/app/ProfileRootMessage.tsx b/src/components/app/ProfileRootMessage.tsx index 22db129401..3b1926b0f2 100644 --- a/src/components/app/ProfileRootMessage.tsx +++ b/src/components/app/ProfileRootMessage.tsx @@ -7,18 +7,40 @@ import * as React from 'react'; import './ProfileRootMessage.css'; +type DownloadProgressInfo = { + readonly receivedBytes: number; + readonly totalBytes: number | null; +}; + type Props = { readonly title?: string; readonly additionalMessage: React.ReactNode; readonly showLoader: boolean; readonly showBackHomeLink: boolean; + readonly downloadProgress?: DownloadProgressInfo | null; readonly children: React.ReactNode; }; +function _formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export class ProfileRootMessage extends React.PureComponent { override render() { - const { children, additionalMessage, showLoader, showBackHomeLink, title } = - this.props; + const { + children, + additionalMessage, + showLoader, + showBackHomeLink, + downloadProgress, + title, + } = this.props; return (
@@ -29,6 +51,9 @@ export class ProfileRootMessage extends React.PureComponent {

{children}

+ {downloadProgress + ? this._renderDownloadProgress(downloadProgress) + : null} {additionalMessage ? (
{additionalMessage}
) : null} @@ -41,7 +66,7 @@ export class ProfileRootMessage extends React.PureComponent {
) : null} - {showLoader ? ( + {showLoader && !downloadProgress ? (
@@ -59,4 +84,63 @@ export class ProfileRootMessage extends React.PureComponent {
); } + + _renderDownloadProgress( + downloadProgress: DownloadProgressInfo + ): React.ReactNode { + const receivedStr = _formatBytes(downloadProgress.receivedBytes); + const progressText = downloadProgress.totalBytes + ? `${receivedStr} / ${_formatBytes(downloadProgress.totalBytes)}` + : `${receivedStr} downloaded`; + + return ( +
+ +
+ {downloadProgress.totalBytes ? ( +
+ ) : ( +
+ )} +
+ +
+ {downloadProgress.totalBytes ? ( + + {progressText} + + ) : ( + + {progressText} + + )} +
+
+ ); + } } diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 5c3653dfd9..d0b9366232 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -34,6 +34,14 @@ const view: Reducer = ( }; case 'FATAL_ERROR': return { phase: 'FATAL_ERROR', error: action.error }; + case 'PROFILE_DOWNLOAD_PROGRESS': + return { + phase: 'INITIALIZING', + downloadProgress: { + receivedBytes: action.receivedBytes, + totalBytes: action.totalBytes, + }, + }; case 'WAITING_FOR_PROFILE_FROM_BROWSER': case 'WAITING_FOR_PROFILE_FROM_URL': case 'WAITING_FOR_PROFILE_FROM_FILE': diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index 0b336a05d8..8357e4003d 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -974,6 +974,12 @@ describe('actions/receive-profile', function () { message: errorMessage, }, }, + expect.objectContaining({ + phase: 'INITIALIZING', + downloadProgress: expect.objectContaining({ + receivedBytes: expect.any(Number), + }), + }), { phase: 'PROFILE_LOADED' }, { phase: 'DATA_LOADED' }, ]); @@ -1098,6 +1104,12 @@ describe('actions/receive-profile', function () { message: errorMessage, }, }, + expect.objectContaining({ + phase: 'INITIALIZING', + downloadProgress: expect.objectContaining({ + receivedBytes: expect.any(Number), + }), + }), { phase: 'PROFILE_LOADED' }, { phase: 'DATA_LOADED' }, ]); diff --git a/src/types/actions.ts b/src/types/actions.ts index b3dccd0f99..0ce88d588c 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -436,6 +436,11 @@ type ReceiveProfileAction = readonly profileUrl: string | null; } | { readonly type: 'TRIGGER_LOADING_FROM_URL'; readonly profileUrl: string } + | { + readonly type: 'PROFILE_DOWNLOAD_PROGRESS'; + readonly receivedBytes: number; + readonly totalBytes: number | null; + } | { readonly type: 'UPDATE_PAGES'; readonly newPages: PageList; diff --git a/src/types/state.ts b/src/types/state.ts index 6e18bb2630..236d6faa50 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -125,6 +125,10 @@ export type AppViewState = readonly attempt: Attempt | null; readonly message: string; }; + readonly downloadProgress?: { + readonly receivedBytes: number; + readonly totalBytes: number | null; + }; }; export type Phase = AppViewState['phase'];