Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions locales/en-US/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
165 changes: 147 additions & 18 deletions src/actions/receive-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, DownloadProgress>();
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<any>) => void;
};
Expand All @@ -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<ArrayBuffer> {
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
Expand All @@ -1032,7 +1145,7 @@ export async function _fetchProfile(
): Promise<ProfileOrZip> {
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;

Expand All @@ -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.
Expand Down Expand Up @@ -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<any>) => void
): Promise<ProfileOrZip> {
Expand All @@ -1123,15 +1241,16 @@ 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:
// The content type is null if it is unknown, or an unsupported type. Go ahead
// and try to process it as a profile.
return {
responseType: 'PROFILE',
profile: await _extractJsonFromResponse(
profile: await _extractJsonFromBuffer(
buffer,
response,
reportError,
contentType
Expand All @@ -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<any>) => void
): Promise<JSZip> {
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
Expand Down Expand Up @@ -1190,29 +1309,27 @@ 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<any>) => void,
fileType: 'application/json' | null
): Promise<unknown> {
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;
if (error && typeof error === 'object' && error.name === 'AbortError') {
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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1434,6 +1558,8 @@ export function retrieveProfilesToCompare(
dispatch(waitingForProfileFromUrl());

try {
const reportAggregatedProgress =
_makeAggregatedProgressDispatcher(dispatch);
const profilesAndStates = await Promise.all(
profileViewUrls.map(async (url) => {
if (
Expand All @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions src/components/app/ProfileLoaderAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,16 @@ class ProfileLoaderAnimationImpl extends PureComponent<ProfileLoaderAnimationPro
dataSource === 'from-file'
);

const downloadProgress =
'downloadProgress' in view ? view.downloadProgress : null;

return (
<Localized id={message} attrs={{ title: true }} elems={{ a: <span /> }}>
<ProfileRootMessage
additionalMessage={this._renderAdditionalMessage()}
showLoader={showLoader}
showBackHomeLink={showBackHomeLink}
downloadProgress={downloadProgress}
>{`Untranslated ${message}`}</ProfileRootMessage>
</Localized>
);
Expand Down
50 changes: 50 additions & 0 deletions src/components/app/ProfileRootMessage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading