Skip to content
Draft
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
2 changes: 2 additions & 0 deletions locales/en-US/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,8 @@ MenuButtons--publish--cancel-upload = Cancel Upload
MenuButtons--publish--message-something-went-wrong = Uh oh, something went wrong when uploading the profile.
MenuButtons--publish--message-try-again = Try again
MenuButtons--publish--download = Download
MenuButtons--publish--download-json = Download as JSON
MenuButtons--publish--download-json-error = Error preparing JSON download
MenuButtons--publish--compressing = Compressing…
MenuButtons--publish--error-while-compressing = Error while compressing, try unchecking some checkboxes to reduce the profile size.

Expand Down
99 changes: 68 additions & 31 deletions src/actions/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ import type {
ProfileIndexTranslationMaps,
ProfileEncodingResult,
SharingMode,
PublishProfileFormat,
} from 'firefox-profiler/types';
import { compress } from 'firefox-profiler/utils/gz';
import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile';
import {
optimizeProfileForStorage,
serializeProfileToJsonSlabsFile,
serializeProfileToJsonString,
} from 'firefox-profiler/profile-logic/process-profile';

export function updateSharingOption(
mode: SharingMode,
Expand All @@ -78,33 +83,45 @@ export function updateSharingOption(
}

export function sanitizedProfileEncodingStarted(
mode: SharingMode,
format: PublishProfileFormat,
sanitizedProfile: Profile,
encodingPromise: Promise<ProfileEncodingResult>
): Action {
return {
type: 'SANITIZED_PROFILE_ENCODING_STARTED',
mode,
format,
sanitizedProfile,
encodingPromise,
};
}

export function sanitizedProfileEncodingCompleted(
mode: SharingMode,
format: PublishProfileFormat,
sanitizedProfile: Profile,
profileData: Blob
): Action {
return {
type: 'SANITIZED_PROFILE_ENCODING_COMPLETED',
mode,
format,
sanitizedProfile,
profileData,
};
}

export function sanitizedProfileEncodingFailed(
mode: SharingMode,
format: PublishProfileFormat,
sanitizedProfile: Profile,
error: Error
): Action {
return {
type: 'SANITIZED_PROFILE_ENCODING_FAILED',
mode,
format,
sanitizedProfile,
error,
};
Expand Down Expand Up @@ -268,10 +285,21 @@ function unwrapEncodedProfile(encodingResult: ProfileEncodingResult): Blob {
return encodingResult.profileData;
}

export type InflightProfileEncoding = {
sanitizedProfile: Profile;
encodingPromise: Promise<ProfileEncodingResult>;
};
function serializeSanitizedProfile(
format: PublishProfileFormat,
sanitizedProfile: Profile
): string | Uint8Array<ArrayBuffer> {
switch (format) {
case 'jslb':
return serializeProfileToJsonSlabsFile(
optimizeProfileForStorage(sanitizedProfile)
);
case 'json':
return serializeProfileToJsonString(sanitizedProfile);
default:
throw new Error(`Unknown publish format: ${format as string}`);
}
}

/**
* Kick off "encoding" of the sanitized profile. Specifically this means:
Expand All @@ -281,69 +309,77 @@ export type InflightProfileEncoding = {
*
* The asynchronous compression can take a few seconds, so we kick it off
* immediately when the download or share panel is opened. The in-flight
* compression is tracked in the redux state, so opening the other panel or
* compression is tracked in the redux state, so reopening the panel or
* pressing Upload reuses it (for the same sanitized profile) instead of
* compressing again. The returned promise lets the caller wait on the
* compressed result.
*
* This thunk action is synchronous.
*/
export function encodeSanitizedProfile(
mode: SharingMode
): ThunkAction<InflightProfileEncoding> {
return (dispatch, getState): InflightProfileEncoding => {
mode: SharingMode,
format: PublishProfileFormat
): ThunkAction<Promise<ProfileEncodingResult>> {
return (dispatch, getState): Promise<ProfileEncodingResult> => {
const state = getState();
const sanitizedProfile = getSanitizedProfile(state, mode).profile;

const encodingState = getSanitizedProfileEncodingState(state);
const encodingState = getSanitizedProfileEncodingState(state, mode, format);
if (
encodingState.phase === 'ENCODING' &&
encodingState.sanitizedProfile === sanitizedProfile
) {
// A compression for this profile is already in-flight; reuse it.
return {
sanitizedProfile,
encodingPromise: encodingState.encodingPromise,
};
return encodingState.encodingPromise;
}
if (
encodingState.phase === 'DONE' &&
encodingState.sanitizedProfile === sanitizedProfile
) {
// We already have an encoded version of this profile in our state! Use it.
return {
sanitizedProfile,
encodingPromise: Promise.resolve({
type: 'SUCCESS',
profileData: encodingState.profileData,
}),
};
return Promise.resolve({
type: 'SUCCESS',
profileData: encodingState.profileData,
});
}

// Kick off a new encoding for this profile. Don't await the promise,
// just return it as part of the InflightProfileEncoding.
// just hand it back to the caller.
const encodingPromise: Promise<ProfileEncodingResult> = (async function () {
// Yield so the ENCODING phase (dispatched below) is in the store before a
// synchronous failure here could dispatch FAILED, which the reducer drops.
await Promise.resolve();
try {
const gzipData = await compress(
serializeProfileToJsonString(sanitizedProfile)
);
const serialized = serializeSanitizedProfile(format, sanitizedProfile);
const gzipData = await compress(serialized);
const blob = new Blob([gzipData], { type: 'application/octet-binary' });
dispatch(sanitizedProfileEncodingCompleted(sanitizedProfile, blob));
dispatch(
sanitizedProfileEncodingCompleted(
mode,
format,
sanitizedProfile,
blob
)
);
return { type: 'SUCCESS', profileData: blob };
} catch (error) {
dispatch(sanitizedProfileEncodingFailed(sanitizedProfile, error));
dispatch(
sanitizedProfileEncodingFailed(mode, format, sanitizedProfile, error)
);
console.error('Error while compressing the profile data', error);
return { type: 'ERROR', error };
}
})();

dispatch(
sanitizedProfileEncodingStarted(sanitizedProfile, encodingPromise)
sanitizedProfileEncodingStarted(
mode,
format,
sanitizedProfile,
encodingPromise
)
);
return { sanitizedProfile, encodingPromise };
return encodingPromise;
};
}

Expand Down Expand Up @@ -394,8 +430,9 @@ export function attemptToPublish(): ThunkAction<Promise<boolean>> {
prePublishedState,
'upload'
);
const profileEncoding = dispatch(encodeSanitizedProfile('upload'));
const encodingResult = await profileEncoding.encodingPromise;
const encodingResult = await dispatch(
encodeSanitizedProfile('upload', 'jslb')
);

// The previous line was async, check to make sure that this request is still valid.
// The upload could have been aborted while we were compressing the data.
Expand Down
48 changes: 48 additions & 0 deletions src/components/app/MenuButtons/Publish.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,54 @@
background-image: url(../../../../res/img/svg/download-light.svg);
}

/* Split-button styling: primary "Download" button on the left, dropdown
* toggle on the right, visually joined. */
.publishPanelSplitButton {
display: inline-flex;
margin: 0 8px;
}

.publishPanelSplitButtonMain {
margin: 0;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}

.publishPanelSplitButtonToggleButton {
position: relative;
width: 24px;
min-width: 0;
height: 32px;
padding: 0;
border-left: 1px solid var(--grey-30-a60, rgb(12 12 13 / 0.2));
border-bottom-left-radius: 0;
border-top-left-radius: 0;
cursor: default;
}

/* Down-caret drawn with borders, matching the marker-chart filter/copy
* buttons' style. */
.publishPanelSplitButtonToggleButton::after {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-top: 5px solid;
border-right: 4px solid transparent;
border-left: 4px solid transparent;
color: var(--clickable-foreground-color);
content: '';
transform: translate(-50%, -25%);
}

/* Pin the download-format menu to a fixed width. The JS side subtracts this
* width from the toggle button's right edge to right-anchor the menu; the
* two must stay in sync. */
.publishPanelDownloadFormatContextMenu {
width: 220px;
}

.menuButtonsDownloadSize {
display: inline-block;
margin: 0 4px;
Expand Down
Loading
Loading