Skip to content

Commit 6493716

Browse files
kim-emclaude
andcommitted
Display download progress bar when loading profiles from URLs
When loading profiles via from-url or public data sources, the app previously showed only a decorative animation with no indication of download progress. This replaces that with a real progress bar using the ReadableStream API to track bytes received. - Stream response body via response.body.getReader() instead of response.arrayBuffer() to report progress during download - Pre-allocate buffer when Content-Length is known to avoid 2x memory spike; fall back to chunk accumulation when unknown or wrong - Throttle progress dispatches to ~10/s to avoid excessive renders - Show determinate bar with "X MB / Y MB" when Content-Length known, indeterminate animation with "X MB downloaded" otherwise - Aggregate progress across parallel downloads in compare mode - Add ARIA progressbar role, aria-valuetext, and localized aria-label - Add Fluent localization strings for all user-visible text - Respect prefers-reduced-motion: reduce Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 484cfd2 commit 6493716

10 files changed

Lines changed: 334 additions & 21 deletions

File tree

locales/en-US/app.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,18 @@ ProfileLoaderAnimation--loading-view-not-found = View not found
836836

837837
ProfileRootMessage--title = { -profiler-brand-name }
838838
ProfileRootMessage--additional = Back to home
839+
# This string is used as the accessible label for the download progress bar.
840+
ProfileRootMessage--download-progress-label =
841+
.aria-label = Download progress
842+
# This string is displayed when the total download size is known.
843+
# Variables:
844+
# $receivedSize (String) - Amount of data received so far, e.g. "3.2 MB"
845+
# $totalSize (String) - Total download size, e.g. "14.5 MB"
846+
ProfileRootMessage--download-progress-known = { $receivedSize } / { $totalSize }
847+
# This string is displayed when the total download size is unknown.
848+
# Variables:
849+
# $receivedSize (String) - Amount of data received so far, e.g. "3.2 MB"
850+
ProfileRootMessage--download-progress-unknown = { $receivedSize } downloaded
839851
840852
## Root
841853

src/actions/receive-profile.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
fetchProfile,
77
getProfileUrlForHash,
88
type ProfileOrZip,
9+
type DownloadProgress,
910
deduceContentType,
1011
extractJsonFromArrayBuffer,
1112
} from 'firefox-profiler/utils/profile-fetch';
@@ -994,6 +995,30 @@ export function retrieveProfileFromStore(
994995
return retrieveProfileOrZipFromUrl(getProfileUrlForHash(hash), initialLoad);
995996
}
996997

998+
/**
999+
* Create a callback that aggregates download progress across multiple parallel
1000+
* fetches and dispatches the combined totals. Used for compare-mode downloads.
1001+
*/
1002+
function _makeAggregatedProgressDispatcher(
1003+
dispatch: (receivedBytes: number, totalBytes: number | null) => void
1004+
): (key: string, progress: DownloadProgress) => void {
1005+
const progressByKey = new Map<string, DownloadProgress>();
1006+
return (key: string, progress: DownloadProgress) => {
1007+
progressByKey.set(key, progress);
1008+
let receivedBytes = 0;
1009+
let totalBytes: number | null = 0;
1010+
for (const p of progressByKey.values()) {
1011+
receivedBytes += p.receivedBytes;
1012+
if (totalBytes !== null && p.totalBytes !== null) {
1013+
totalBytes += p.totalBytes;
1014+
} else {
1015+
totalBytes = null;
1016+
}
1017+
}
1018+
dispatch(receivedBytes, totalBytes);
1019+
};
1020+
}
1021+
9971022
/**
9981023
* Runs a fetch on a URL, and downloads the file. If it's JSON, then it attempts
9991024
* to process the profile. If it's a zip file, it tries to unzip it, and save it
@@ -1012,6 +1037,13 @@ export function retrieveProfileOrZipFromUrl(
10121037
onTemporaryError: (e: TemporaryError) => {
10131038
dispatch(temporaryError(e));
10141039
},
1040+
onDownloadProgress: ({ receivedBytes, totalBytes }) => {
1041+
dispatch({
1042+
type: 'PROFILE_DOWNLOAD_PROGRESS',
1043+
receivedBytes,
1044+
totalBytes,
1045+
});
1046+
},
10151047
});
10161048

10171049
switch (response.responseType) {
@@ -1178,6 +1210,16 @@ export function retrieveProfilesToCompare(
11781210
return async (dispatch) => {
11791211
dispatch(waitingForProfileFromUrl());
11801212

1213+
const reportAggregatedProgress = _makeAggregatedProgressDispatcher(
1214+
(receivedBytes, totalBytes) => {
1215+
dispatch({
1216+
type: 'PROFILE_DOWNLOAD_PROGRESS',
1217+
receivedBytes,
1218+
totalBytes,
1219+
});
1220+
}
1221+
);
1222+
11811223
try {
11821224
const profilesAndStates = await Promise.all(
11831225
profileViewUrls.map(async (url) => {
@@ -1196,6 +1238,9 @@ export function retrieveProfilesToCompare(
11961238
onTemporaryError: (e: TemporaryError) => {
11971239
dispatch(temporaryError(e));
11981240
},
1241+
onDownloadProgress: (progress: DownloadProgress) => {
1242+
reportAggregatedProgress(profileUrl, progress);
1243+
},
11991244
});
12001245
if (response.responseType !== 'PROFILE') {
12011246
throw new Error('Expected to receive a profile from fetchProfile');

src/components/app/ProfileLoaderAnimation.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,16 @@ class ProfileLoaderAnimationImpl extends PureComponent<ProfileLoaderAnimationPro
6565
dataSource === 'from-file'
6666
);
6767

68+
const downloadProgress =
69+
'downloadProgress' in view ? view.downloadProgress : null;
70+
6871
return (
6972
<Localized id={message} attrs={{ title: true }} elems={{ a: <span /> }}>
7073
<ProfileRootMessage
7174
additionalMessage={this._renderAdditionalMessage()}
7275
showLoader={showLoader}
7376
showBackHomeLink={showBackHomeLink}
77+
downloadProgress={downloadProgress}
7478
>{`Untranslated ${message}`}</ProfileRootMessage>
7579
</Localized>
7680
);

src/components/app/ProfileRootMessage.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,56 @@
4343
font-size: 12px;
4444
}
4545

46+
.downloadProgress {
47+
margin-top: 16px;
48+
}
49+
50+
.downloadProgressBarTrack {
51+
overflow: hidden;
52+
height: 8px;
53+
border-radius: 4px;
54+
background-color: var(--grey-30);
55+
}
56+
57+
.downloadProgressBarFill {
58+
height: 100%;
59+
border-radius: 4px;
60+
background-color: var(--blue-60);
61+
transition: width 150ms ease-out;
62+
}
63+
64+
.downloadProgressBarFillIndeterminate {
65+
width: 30%;
66+
height: 100%;
67+
border-radius: 4px;
68+
animation: indeterminateProgress 1.5s ease-in-out infinite;
69+
background-color: var(--blue-60);
70+
}
71+
72+
@keyframes indeterminateProgress {
73+
0% {
74+
transform: translateX(-100%);
75+
}
76+
77+
100% {
78+
transform: translateX(433%);
79+
}
80+
}
81+
82+
@media (prefers-reduced-motion: reduce) {
83+
.downloadProgressBarFillIndeterminate {
84+
width: 100%;
85+
animation: none;
86+
opacity: 0.5;
87+
}
88+
}
89+
90+
.downloadProgressText {
91+
margin-top: 4px;
92+
color: var(--grey-50);
93+
font-size: 12px;
94+
}
95+
4696
.loading {
4797
position: relative;
4898
height: 40px;

src/components/app/ProfileRootMessage.tsx

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,40 @@ import * as React from 'react';
77

88
import './ProfileRootMessage.css';
99

10+
type DownloadProgressInfo = {
11+
readonly receivedBytes: number;
12+
readonly totalBytes: number | null;
13+
};
14+
1015
type Props = {
1116
readonly title?: string;
1217
readonly additionalMessage: React.ReactNode;
1318
readonly showLoader: boolean;
1419
readonly showBackHomeLink: boolean;
20+
readonly downloadProgress?: DownloadProgressInfo | null;
1521
readonly children: React.ReactNode;
1622
};
1723

24+
function _formatBytes(bytes: number): string {
25+
if (bytes < 1024) {
26+
return `${bytes} B`;
27+
}
28+
if (bytes < 1024 * 1024) {
29+
return `${(bytes / 1024).toFixed(1)} KB`;
30+
}
31+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
32+
}
33+
1834
export class ProfileRootMessage extends React.PureComponent<Props> {
1935
override render() {
20-
const { children, additionalMessage, showLoader, showBackHomeLink, title } =
21-
this.props;
36+
const {
37+
children,
38+
additionalMessage,
39+
showLoader,
40+
showBackHomeLink,
41+
downloadProgress,
42+
title,
43+
} = this.props;
2244
return (
2345
<div className="rootMessageContainer">
2446
<div className="rootMessage">
@@ -29,6 +51,9 @@ export class ProfileRootMessage extends React.PureComponent<Props> {
2951
<div className="rootMessageText">
3052
<p>{children}</p>
3153
</div>
54+
{downloadProgress
55+
? this._renderDownloadProgress(downloadProgress)
56+
: null}
3257
{additionalMessage ? (
3358
<div className="rootMessageAdditional">{additionalMessage}</div>
3459
) : null}
@@ -41,7 +66,7 @@ export class ProfileRootMessage extends React.PureComponent<Props> {
4166
</a>
4267
</div>
4368
) : null}
44-
{showLoader ? (
69+
{showLoader && !downloadProgress ? (
4570
<div className="loading">
4671
<div className="loading-div loading-div-1 loading-row-1" />
4772
<div className="loading-div loading-div-2 loading-row-2" />
@@ -59,4 +84,63 @@ export class ProfileRootMessage extends React.PureComponent<Props> {
5984
</div>
6085
);
6186
}
87+
88+
_renderDownloadProgress(
89+
downloadProgress: DownloadProgressInfo
90+
): React.ReactNode {
91+
const receivedStr = _formatBytes(downloadProgress.receivedBytes);
92+
const progressText = downloadProgress.totalBytes
93+
? `${receivedStr} / ${_formatBytes(downloadProgress.totalBytes)}`
94+
: `${receivedStr} downloaded`;
95+
96+
return (
97+
<div className="downloadProgress">
98+
<Localized
99+
id="ProfileRootMessage--download-progress-label"
100+
attrs={{ 'aria-label': true }}
101+
>
102+
<div
103+
className="downloadProgressBarTrack"
104+
role="progressbar"
105+
aria-valuenow={downloadProgress.receivedBytes}
106+
aria-valuemin={0}
107+
aria-valuemax={downloadProgress.totalBytes ?? undefined}
108+
aria-valuetext={progressText}
109+
aria-label="Download progress"
110+
>
111+
{downloadProgress.totalBytes ? (
112+
<div
113+
className="downloadProgressBarFill"
114+
style={{
115+
width: `${Math.min(100, (downloadProgress.receivedBytes / downloadProgress.totalBytes) * 100)}%`,
116+
}}
117+
/>
118+
) : (
119+
<div className="downloadProgressBarFillIndeterminate" />
120+
)}
121+
</div>
122+
</Localized>
123+
<div className="downloadProgressText">
124+
{downloadProgress.totalBytes ? (
125+
<Localized
126+
id="ProfileRootMessage--download-progress-known"
127+
vars={{
128+
receivedSize: receivedStr,
129+
totalSize: _formatBytes(downloadProgress.totalBytes),
130+
}}
131+
>
132+
{progressText}
133+
</Localized>
134+
) : (
135+
<Localized
136+
id="ProfileRootMessage--download-progress-unknown"
137+
vars={{ receivedSize: receivedStr }}
138+
>
139+
{progressText}
140+
</Localized>
141+
)}
142+
</div>
143+
</div>
144+
);
145+
}
62146
}

src/reducers/app.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ const view: Reducer<AppViewState> = (
3434
};
3535
case 'FATAL_ERROR':
3636
return { phase: 'FATAL_ERROR', error: action.error };
37+
case 'PROFILE_DOWNLOAD_PROGRESS':
38+
return {
39+
phase: 'INITIALIZING',
40+
downloadProgress: {
41+
receivedBytes: action.receivedBytes,
42+
totalBytes: action.totalBytes,
43+
},
44+
};
3745
case 'WAITING_FOR_PROFILE_FROM_BROWSER':
3846
case 'WAITING_FOR_PROFILE_FROM_URL':
3947
case 'WAITING_FOR_PROFILE_FROM_FILE':

src/test/store/receive-profile.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,12 @@ describe('actions/receive-profile', function () {
958958
message: errorMessage,
959959
},
960960
},
961+
expect.objectContaining({
962+
phase: 'INITIALIZING',
963+
downloadProgress: expect.objectContaining({
964+
receivedBytes: expect.any(Number),
965+
}),
966+
}),
961967
{ phase: 'PROFILE_LOADED' },
962968
{ phase: 'DATA_LOADED' },
963969
]);
@@ -1082,6 +1088,12 @@ describe('actions/receive-profile', function () {
10821088
message: errorMessage,
10831089
},
10841090
},
1091+
expect.objectContaining({
1092+
phase: 'INITIALIZING',
1093+
downloadProgress: expect.objectContaining({
1094+
receivedBytes: expect.any(Number),
1095+
}),
1096+
}),
10851097
{ phase: 'PROFILE_LOADED' },
10861098
{ phase: 'DATA_LOADED' },
10871099
]);

src/types/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@ type ReceiveProfileAction =
436436
readonly profileUrl: string | null;
437437
}
438438
| { readonly type: 'TRIGGER_LOADING_FROM_URL'; readonly profileUrl: string }
439+
| {
440+
readonly type: 'PROFILE_DOWNLOAD_PROGRESS';
441+
readonly receivedBytes: number;
442+
readonly totalBytes: number | null;
443+
}
439444
| {
440445
readonly type: 'UPDATE_PAGES';
441446
readonly newPages: PageList;

src/types/state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export type AppViewState =
125125
readonly attempt: Attempt | null;
126126
readonly message: string;
127127
};
128+
readonly downloadProgress?: {
129+
readonly receivedBytes: number;
130+
readonly totalBytes: number | null;
131+
};
128132
};
129133

130134
export type Phase = AppViewState['phase'];

0 commit comments

Comments
 (0)