UNPKG

@zendesk/react-measure-timing-hooks

Version:

react hooks for measuring time to interactive and time to render of components

522 lines 21.8 kB
"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