@zendesk/react-measure-timing-hooks
Version:
react hooks for measuring time to interactive and time to render of components
522 lines • 21.8 kB
JavaScript
"use strict";
/* eslint-disable no-underscore-dangle */
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ActionLog = void 0;
const constants_1 = require("./constants");
const debounce_1 = require("./debounce");
const performanceMark_1 = require("./performanceMark");
const utilities_1 = require("./utilities");
const NO_IMMEDIATE_SEND_STAGES = [];
const NO_FINAL_STAGES = [];
class ActionLog {
actions = [];
onActionAddedCallback = undefined;
lastStage = constants_1.INFORMATIVE_STAGES.INITIAL;
lastStageUpdatedAt = performance.now();
get lastStageEntry() {
const lastStageChangeEntry = this.lastStageChange?.entry;
if (lastStageChangeEntry)
return lastStageChangeEntry;
const lastRenderEntry = this.actions.find((action) => action.type === constants_1.ACTION_TYPE.RENDER)?.entry;
return lastRenderEntry?.startMark ?? lastRenderEntry;
}
lastStageBySource = new Map();
finalStages = NO_FINAL_STAGES;
loadingStages = constants_1.DEFAULT_LOADING_STAGES;
immediateSendReportStages = NO_IMMEDIATE_SEND_STAGES;
dependenciesBySource = new Map();
hasReportedAtLeastOnce = false;
flushUponDeactivation = false;
customMetadataBySource = new Map();
reportedErrors = new WeakSet();
/**
* Returns the PerformanceEntry from the last render, or undefined if no render has completed.
* Enables verifying whether timing beacons have rendered even outside the scope of the timingHook.
*/
getLastRenderedActionEntry() {
return this.actions.find((action) => action.type === constants_1.ACTION_TYPE.RENDER)
?.entry;
}
getActions() {
return this.actions;
}
/**
* Clear performance marks that were added by this ActionLog instance.
*/
clearPerformanceMarks() {
this.actions.forEach((action) => {
if (!action.entry.name)
return;
try {
if (action.entry instanceof PerformanceMeasure) {
performance.clearMeasures(action.entry.name);
}
}
catch {
// ignore
}
});
}
/**
* Clear parts of the internal state, so it's ready for the next measurement.
*/
clear() {
if (this.willFlushTimeout) {
clearTimeout(this.willFlushTimeout);
this.willFlushTimeout = undefined;
// flush immediately
this.debouncedTrigger.flush('clear');
}
else {
this.debouncedTrigger.cancel();
}
this.stopObserving();
this.clearPerformanceMarks();
this.actions = [];
this.lastStage = constants_1.INFORMATIVE_STAGES.INITIAL;
this.lastStageUpdatedAt = performance.now();
this.lastStageBySource.clear();
}
/**
* Complete reset of internal state,
* except for configuration options which are always updated on render.
*/
reset() {
this.debouncedTrigger.reset();
this.clear();
this.ensureReporting();
this.hasReportedAtLeastOnce = false;
this.placementsCurrentlyRenderable.clear();
}
reportFn = utilities_1.noop;
shouldResetOnDependencyChangeFnBySource = new Map();
id = 'default';
wasImported = false;
onInternalError =
// only a default, to be overridden in usage
// eslint-disable-next-line no-console
console.error;
minimumExpectedSimultaneousBeacons;
placementsCurrentlyRenderable = new Set();
waitForBeaconActivation = [];
get isInUse() {
return this.placementsCurrentlyRenderable.size > 0;
}
getId() {
return this.id;
}
constructor(options) {
this.updateStaticOptions(options);
}
updateStaticOptions({ debounceMs, timeoutMs, finalStages, loadingStages, immediateSendReportStages, minimumExpectedSimultaneousBeacons, waitForBeaconActivation, flushUponDeactivation, reportFn, onActionAddedCallback, onInternalError, }) {
if (typeof minimumExpectedSimultaneousBeacons === 'number') {
this.minimumExpectedSimultaneousBeacons =
minimumExpectedSimultaneousBeacons;
}
if (onInternalError)
this.onInternalError = onInternalError;
if (reportFn)
this.reportFn = reportFn;
if (onActionAddedCallback) {
this.onActionAddedCallback = onActionAddedCallback;
}
this.debounceOptionsRef.debounceMs = debounceMs ?? constants_1.DEFAULT_DEBOUNCE_MS;
this.debounceOptionsRef.timeoutMs = timeoutMs ?? constants_1.DEFAULT_TIMEOUT_MS;
this.finalStages = finalStages ?? NO_FINAL_STAGES;
this.loadingStages = loadingStages ?? constants_1.DEFAULT_LOADING_STAGES;
this.immediateSendReportStages =
immediateSendReportStages ?? NO_IMMEDIATE_SEND_STAGES;
this.flushUponDeactivation = flushUponDeactivation ?? false;
this.waitForBeaconActivation = waitForBeaconActivation ?? [];
}
/**
* Use to import internal state from another ActionLog instance.
*/
importState(otherLog) {
this.hasReportedAtLeastOnce =
this.hasReportedAtLeastOnce || otherLog.hasReportedAtLeastOnce;
if (this.reportFn === utilities_1.noop)
this.reportFn = otherLog.reportFn;
if (!this.onInternalError)
this.onInternalError = otherLog.onInternalError;
if (otherLog.lastStage !== constants_1.INFORMATIVE_STAGES.INITIAL &&
otherLog.lastStageUpdatedAt > this.lastStageUpdatedAt) {
this.lastStage = otherLog.lastStage;
this.lastStageUpdatedAt = otherLog.lastStageUpdatedAt;
}
otherLog.lastStageBySource.forEach((stage, source) => {
this.lastStageBySource.set(source, stage);
});
otherLog.dependenciesBySource.forEach((dependencies, source) => {
this.dependenciesBySource.set(source, dependencies);
});
otherLog.placementsCurrentlyRenderable.forEach((placement) => {
this.placementsCurrentlyRenderable.add(placement);
});
otherLog.shouldResetOnDependencyChangeFnBySource.forEach((shouldResetOnDependencyChangeFn, source) => {
this.shouldResetOnDependencyChangeFnBySource.set(source, shouldResetOnDependencyChangeFn);
});
otherLog.actions.forEach((action) => {
this.insertActionInOrder(action);
});
this.onActionAdded();
otherLog.setAsImported();
}
setAsImported() {
this.wasImported = true;
this.debouncedTrigger.reset();
this.clear();
this.disableReporting();
this.dispose();
}
updateOptions({ id, reportFn, shouldResetOnDependencyChangeFn, onInternalError, onActionAddedCallback, }, source) {
// any source can change the ID
this.id = id;
if (onInternalError)
this.onInternalError = onInternalError;
if (reportFn)
this.reportFn = reportFn;
if (shouldResetOnDependencyChangeFn) {
this.shouldResetOnDependencyChangeFnBySource.set(source, shouldResetOnDependencyChangeFn);
}
if (onActionAddedCallback) {
this.onActionAddedCallback = onActionAddedCallback;
}
}
/**
* Inserts an action while maintaining order
* @returns {boolean} true when inserted at the very end of actions
*/
insertActionInOrder(action) {
const insertBeforeIndex = this.actions.findIndex((a) => a.timestamp > action.timestamp);
// do not insert unresponsive tasks as first in order, cause they could have started before the actual render
const index = action.type === constants_1.ACTION_TYPE.UNRESPONSIVE && insertBeforeIndex === 0
? 1
: insertBeforeIndex;
this.actions.splice(index === -1 ? this.actions.length : index, 0, action);
return index === -1;
}
addSpan(info) {
this.addAction({
...info,
marker: constants_1.MARKER.START,
timestamp: info.entry.startTime,
}, {
...info,
marker: constants_1.MARKER.END,
timestamp: info.entry.startTime + info.entry.duration,
});
}
addAction(...actions) {
if (!this.isCapturingData)
return;
actions.forEach((action) => {
this.insertActionInOrder({
...action,
mountedPlacements: [...this.placementsCurrentlyRenderable],
timingId: this.id,
});
});
this.onActionAdded();
}
addStageChange(info, previousStage = constants_1.INFORMATIVE_STAGES.INITIAL) {
const { stage, renderEntry } = info;
const previousStageEntry = this.lastStageEntry;
const measureName = previousStageEntry
? `${this.id}/${info.source}/${previousStage}-till-${stage}`
: `${this.id}/${info.source}/start-${stage}`;
const entry = previousStageEntry
? (0, performanceMark_1.performanceMeasure)(measureName, previousStageEntry, renderEntry)
: (0, performanceMark_1.performanceMark)(measureName, { startTime: renderEntry?.startTime });
this.addAction({
...info,
type: constants_1.ACTION_TYPE.STAGE_CHANGE,
marker: constants_1.MARKER.POINT,
entry: Object.assign(entry, { startMark: previousStageEntry }),
timestamp: entry.startTime + entry.duration,
});
}
willFlushTimeout;
onActionAdded() {
this.onActionAddedCallback?.(this);
if (this.isInImmediateSendStage) {
this.stopObserving();
if (this.willFlushTimeout)
return;
// we want to wait till the next frame in case the render completes
// if an error is thrown, the component will unmount and we have a more complete picture
this.willFlushTimeout = setTimeout(() => {
this.willFlushTimeout = undefined;
this.debouncedTrigger.flush('immediate send');
}, 0);
}
else {
this.observe();
this.debouncedTrigger();
}
}
get lastStageChange() {
for (let i = this.actions.length - 1; i >= 0; i--) {
const action = this.actions[i];
if (action && action.type === constants_1.ACTION_TYPE.STAGE_CHANGE)
return action;
}
return undefined;
}
markStage(info) {
const { stage, source } = info;
const previousStageForThisSource = this.lastStageBySource.get(source);
// we don't want different beacons racing with one another with re-renders and switching stages
if (previousStageForThisSource === stage)
return;
const previousStage = this.lastStage;
this.lastStage = stage;
this.lastStageUpdatedAt = performance.now();
this.lastStageBySource.set(source, stage);
if (!this.isInFinalStage) {
// we might have just moved back from a final stage to a non-final one
// in such case, ensure reporting is enabled and reset state:
this.ensureReporting();
}
if (!this.isCapturingData)
return;
if (previousStage !== stage || this.actions.length === 0) {
this.addStageChange(info, this.actions.length === 0 ? constants_1.INFORMATIVE_STAGES.INITIAL : previousStage);
}
}
_shouldReport = true;
get shouldReport() {
return this._shouldReport;
}
ensureReporting() {
if (this._shouldReport)
return;
// should enable reporting if not in final stage!
this._shouldReport = true;
// and starting from scratch:
this.clear();
}
disableReporting() {
this._shouldReport = false;
}
observer = typeof PerformanceObserver !== 'undefined' &&
new PerformanceObserver((entryList) => {
if (!this.isCapturingData)
return;
const entries = entryList.getEntries();
for (const entry of entries) {
this.addSpan({
type: constants_1.ACTION_TYPE.UNRESPONSIVE,
source: constants_1.OBSERVER_SOURCE,
entry,
});
}
});
_isObserving = false;
get isObserving() {
return this._isObserving;
}
observe() {
// this is a no-op on browsers that don't support 'longtask',
// but let's guard anyway:
if (this.observer &&
(0, utilities_1.getCurrentBrowserSupportForNonResponsiveStateDetection)() &&
!this.isObserving) {
this.observer.observe({ entryTypes: ['longtask'] });
this._isObserving = true;
}
}
stopObserving() {
if (!this._isObserving || !this.observer)
return;
this.observer.disconnect();
this._isObserving = false;
}
isCapturingDataBySource = new Map();
onBeaconRemoved(source) {
this.isCapturingDataBySource.delete(source);
this.dependenciesBySource.delete(source);
this.shouldResetOnDependencyChangeFnBySource.delete(source);
this.placementsCurrentlyRenderable.delete(source);
if (!this.isInUse)
this.dispose();
}
dispose() {
this.onDisposeCallbacks.forEach((callback) => void callback());
}
onDisposeCallbacks = new Map();
/**
* schedule action to be called once the ActionLog is no longer used
* @param callback
*/
onDispose(name, callback) {
this.onDisposeCallbacks.set(name, callback);
}
get isCapturingData() {
// if at least one source is inactive, all of reporting is inactive
return (this.shouldReport &&
this.isCapturingDataBySource.size > 0 &&
this.waitForBeaconActivation.every((placement) => this.placementsCurrentlyRenderable.has(placement)) &&
(0, utilities_1.every)(this.isCapturingDataBySource.values(), Boolean));
}
setActive(active, source) {
this.placementsCurrentlyRenderable.add(source);
const wasActive = this.isCapturingData;
const newlyDeactivated = wasActive !== active && !active;
if (this.flushUponDeactivation &&
newlyDeactivated &&
this.lastStage !== constants_1.INFORMATIVE_STAGES.INITIAL) {
// flush any previous measurements
this.debouncedTrigger.flush('deactivation');
}
this.isCapturingDataBySource.set(source, active);
const { isCapturingData } = this;
const newlyActivated = wasActive !== isCapturingData && isCapturingData;
if (newlyActivated) {
// clear state upon activation
this.clear();
}
}
get isInFinalStage() {
return (this.finalStages.every((stage) => constants_1.ERROR_STAGES.includes(stage)) ||
this.finalStages.includes(this.lastStage));
}
get isInImmediateSendStage() {
return (constants_1.ERROR_STAGES.includes(this.lastStage) ||
this.immediateSendReportStages.includes(this.lastStage));
}
getRenderedCountBySource(sourceToFilterBy) {
return this.actions.filter(({ type, marker, source }) => source === sourceToFilterBy &&
type === constants_1.ACTION_TYPE.RENDER &&
marker === constants_1.MARKER.END).length;
}
get lastRenderAction() {
return [...this.actions]
.reverse()
.find(({ type }) => type === constants_1.ACTION_TYPE.RENDER);
}
markDependencyChange(mark, source) {
this.addAction({
type: constants_1.ACTION_TYPE.DEPENDENCY_CHANGE,
entry: mark,
timestamp: mark.startTime,
marker: constants_1.MARKER.POINT,
source,
});
}
onExternalDependenciesChange(newDependencies, timestamp, source) {
// any dependency change should re-enable reporting:
this.ensureReporting();
const oldDependencies = this.dependenciesBySource.get(source) ?? [];
this.dependenciesBySource.set(source, newDependencies);
// the first time this runs we wouldn't have captured anything
if (this.getRenderedCountBySource(source) === 0)
return;
const shouldResetFn = this.shouldResetOnDependencyChangeFnBySource.get(source);
const shouldReset = (oldDependencies.length > 0 || newDependencies.length > 0) &&
(!shouldResetFn || shouldResetFn(oldDependencies, newDependencies));
this.markDependencyChange(timestamp, source);
if (!shouldReset)
return;
// we can flush previous measurement (if any) and completely reset
this.debouncedTrigger.flush('dependency change');
this.reset();
}
trigger = (flushReason) => {
const firstAction = this.actions[0];
const lastAction = this.actions[this.actions.length - 1];
const { timeoutMs } = this.debounceOptionsRef;
// The second part or the OR is a workaround for the situation where someone puts their laptop to sleep
// while a feature is in a non-final stage and then opens it many minutes/hours later.
// Timer fires a looong long time after and may not be timed out.
// For this reason we calculate the actual time that passed as an additional guard.
const timedOut = flushReason === debounce_1.TimeoutReason ||
(typeof timeoutMs === 'number' &&
(lastAction?.timestamp ?? 0) - (firstAction?.timestamp ?? 0) >
timeoutMs);
const shouldContinue = timedOut || this.isInFinalStage || this.isInImmediateSendStage;
if (!shouldContinue) {
// UI is not ready yet (probably data loading),
// there's gonna be more renders soon...
// return true to keep the timeout
return true;
}
if (this.actions.length === 0 || !firstAction || !lastAction) {
// nothing to report:
this.stopObserving();
return undefined;
}
const highestNumberOfActiveBeaconsCountAtAnyGivenTime = this.actions
.map((action) => action.mountedPlacements.length)
.sort()
.reverse()[0] ?? 0;
const hadReachedTheRequiredActiveBeaconsCount = typeof this.minimumExpectedSimultaneousBeacons !== 'number' ||
highestNumberOfActiveBeaconsCountAtAnyGivenTime >=
this.minimumExpectedSimultaneousBeacons;
const { lastRenderAction } = this;
const metadataValues = [...this.customMetadataBySource.values()];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const metadata = Object.assign({}, ...metadataValues);
const detail = {
metadata,
timingId: this.id,
isFirstLoad: !this.hasReportedAtLeastOnce,
maximumActiveBeaconsCount: highestNumberOfActiveBeaconsCountAtAnyGivenTime,
minimumExpectedSimultaneousBeacons: this.minimumExpectedSimultaneousBeacons,
flushReason: typeof flushReason === 'symbol'
? flushReason.description ?? 'manual'
: flushReason,
};
let tti;
let ttr;
if (timedOut) {
this.addStageChange({
stage: constants_1.INFORMATIVE_STAGES.TIMEOUT,
source: 'timeout',
}, this.lastStage);
}
else if (lastRenderAction) {
ttr = (0, performanceMark_1.performanceMeasure)(`${this.id}/ttr`, firstAction.entry.startMark ?? firstAction.entry, lastRenderAction.entry.endMark ?? lastRenderAction.entry, detail);
// add a measure so we can use it in Lighthouse runs
tti = (0, performanceMark_1.performanceMeasure)(`${this.id}/tti`, firstAction.entry.startMark ?? firstAction.entry, lastAction.entry.endMark ?? lastAction.entry, detail);
}
const reportArgs = {
...detail,
actions: this.actions,
loadingStages: this.loadingStages,
finalStages: this.finalStages,
immediateSendReportStages: this.immediateSendReportStages.length > 0
? [...constants_1.ERROR_STAGES, ...this.immediateSendReportStages]
: constants_1.ERROR_STAGES,
measures: { tti, ttr },
};
if (this.reportFn === utilities_1.noop) {
this.onInternalError(new Error(`useTiming: reportFn was not set, please set it to a function that will be called with the timing report`), reportArgs);
}
if (hadReachedTheRequiredActiveBeaconsCount) {
this.reportFn(reportArgs);
}
// clear slate for next re-render (stop observing) and disable reporting
this.clear();
this.disableReporting();
if (!timedOut && hadReachedTheRequiredActiveBeaconsCount) {
this.hasReportedAtLeastOnce = true;
}
return undefined;
};
debounceOptionsRef = {
fn: this.trigger,
// these are just the defaults and can be overwritten by the options passed to the constructor:
debounceMs: constants_1.DEFAULT_DEBOUNCE_MS,
timeoutMs: constants_1.DEFAULT_TIMEOUT_MS,
};
debouncedTrigger = (0, debounce_1.debounce)(this.debounceOptionsRef);
}
exports.ActionLog = ActionLog;
//# sourceMappingURL=ActionLog.js.map