Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Starting a new TTVC measurement will now cancel out the previous measurement, if it is still active #85

Merged
merged 3 commits into from
Apr 15, 2024
Merged
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
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ export const start = () => calculator?.start(performance.now());
* @dropbox/ttvc that a user interaction has occurred and continuing the
* measurement may produce an invalid result.
*
* @param eventType The type of event that triggered the cancellation. This will be logged to the error callback.
* @param e The event that triggered the cancellation. This will be logged to the error callback.
*/
export const cancel = (eventType?: string) => calculator?.cancel(eventType);
export const cancel = (e?: Event) => calculator?.cancel(e);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that this is changing the API, so will be a breaking change.


/**
* Call this to notify ttvc that an AJAX request has just begun.
Expand Down
159 changes: 97 additions & 62 deletions src/visuallyCompleteCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export type CancellationError = {
// time since timeOrigin that cancellation occurred
end: number;

// the difference between start and end
duration: number;

// reason for cancellation
cancellationReason: CancellationReason;

Expand All @@ -72,13 +75,33 @@ export const enum CancellationReason {
// user interaction occurred
USER_INTERACTION = 'USER_INTERACTION',

// new TTVC measurement started, overwriting an unfinished one
NEW_MEASUREMENT = 'NEW_MEASUREMENT',

// manual cancellation via API happened
MANUAL_CANCELLATION = 'MANUAL_CANCELLATION',
}

export type MetricSuccessSubscriber = (measurement: Metric) => void;
export type MetricErrorSubscriber = (error: CancellationError) => void;

/**
* Represents a single observation of TTVC measurement, along with its index
* in a session, it's state and a method to cancel the measurement
*
* Observation might result in a successful measurement of TTVC or a failure to capture it
*/
export type Observation = {
index: number;
state: ObservationState;
cancel: (cancellationReason: CancellationReason, e?: Event) => void;
};
const enum ObservationState {
ACTIVE = 'ACTIVE',
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
}

/**
* Core of the TTVC calculation that ties viewport observers and network monitoring
* into a singleton that facilitates communication of TTVC metric measurement and error
Expand All @@ -98,7 +121,9 @@ class VisuallyCompleteCalculator {
private lastImageLoadTimestamp = -1;
private lastImageLoadTarget?: HTMLElement;
private navigationCount = 0;
private activeMeasurementIndex?: number; // only one measurement should be active at a time

// map of TTVC observations
private observations = new Map<number, Observation>();

// subscribers
private successSubscribers = new Set<MetricSuccessSubscriber>();
Expand Down Expand Up @@ -137,36 +162,34 @@ class VisuallyCompleteCalculator {

/**
* expose a method to abort the current TTVC measurement
* @param eventType - type of event that triggered cancellation (note that cancellationReason will be set to "MANUAL_CANCELLATION" regardless of this value).
* @param e - optionally, an event that triggered cancellation
*/
cancel(eventType?: string) {
Logger.info(
'VisuallyCompleteCalculator.cancel()',
'::',
'index =',
this.activeMeasurementIndex
);

if (this.activeMeasurementIndex) {
this.error({
start: getActivationStart(),
end: performance.now(),
cancellationReason: CancellationReason.MANUAL_CANCELLATION,
eventType: eventType,
navigationType: getNavigationType(),
lastVisibleChange: this.getLastVisibleChange(),
});
cancel(e?: Event) {
const mostRecentMeasurement = this.observations.get(this.observations.size);
if (mostRecentMeasurement && mostRecentMeasurement.state === ObservationState.ACTIVE) {
mostRecentMeasurement.cancel(CancellationReason.MANUAL_CANCELLATION, e);
mostRecentMeasurement.state = ObservationState.CANCELLED;
}

this.activeMeasurementIndex = undefined;
}

/** begin measuring a new navigation */
/**
* Begin measuring a new navigation
*
* This async function encompasses an entire TTVC measurement, from the time it is triggered
* to the moment it is finished or cancelled
*
* @param start
* @param isBfCacheRestore
*/
async start(start = 0, isBfCacheRestore = false) {
const navigationIndex = (this.navigationCount += 1);
this.activeMeasurementIndex = navigationIndex;
Logger.info('VisuallyCompleteCalculator.start()', '::', 'index =', navigationIndex);

const previousMeasurement = this.observations.get(navigationIndex - 1);
if (previousMeasurement && previousMeasurement.state === ObservationState.ACTIVE) {
previousMeasurement.cancel(CancellationReason.NEW_MEASUREMENT);
}

let navigationType: NavigationType = isBfCacheRestore
? 'back_forward'
: start > 0
Expand All @@ -179,26 +202,43 @@ class VisuallyCompleteCalculator {
navigationType = 'prerender';
}

// setup
const cancel = (e: Event, cancellationReason: CancellationReason) => {
if (this.activeMeasurementIndex === navigationIndex) {
this.activeMeasurementIndex = undefined;
/**
* Set up a cancellation function for the current TTVC observation
*/
const cancel = (cancellationReason: CancellationReason, e?: Event) => {
const observation = this.observations.get(navigationIndex);

if (!observation || observation.state !== ObservationState.ACTIVE) return;
observation.state = ObservationState.CANCELLED;

this.error({
const end = performance.now();
this.error(
{
start,
end: performance.now(),
end,
duration: end - start,
cancellationReason,
eventType: e.type,
eventTarget: e.target || undefined,
navigationType,
lastVisibleChange: this.getLastVisibleChange(),
});
}
...(e && {
eventType: e.type,
eventTarget: e.target || undefined,
}),
},
observation
);
};

const cancelOnInteraction = (e: Event) => cancel(e, CancellationReason.USER_INTERACTION);
const cancelOnNavigation = (e: Event) => cancel(e, CancellationReason.NEW_NAVIGATION);
const cancelOnVisibilityChange = (e: Event) => cancel(e, CancellationReason.VISIBILITY_CHANGE);
const observation: Observation = {
index: navigationIndex,
state: ObservationState.ACTIVE,
cancel,
};
this.observations.set(navigationIndex, observation);

const cancelOnInteraction = (e: Event) => cancel(CancellationReason.USER_INTERACTION, e);
const cancelOnNavigation = (e: Event) => cancel(CancellationReason.NEW_NAVIGATION, e);
const cancelOnVisibilityChange = (e: Event) => cancel(CancellationReason.VISIBILITY_CHANGE, e);

this.inViewportImageObserver.observe();
this.inViewportMutationObserver.observe(document.documentElement);
Expand All @@ -216,39 +256,34 @@ class VisuallyCompleteCalculator {
// - wait for simultaneous network and CPU idle
const didNetworkTimeOut = await new Promise<boolean>(requestAllIdleCallback);

// if this navigation's measurement hasn't been cancelled, record it.
if (navigationIndex === this.activeMeasurementIndex) {
// if current TTVC observation is still active (was not cancelled), record it
if (observation.state === ObservationState.ACTIVE) {
observation.state = ObservationState.COMPLETED;

// identify timestamp of last visible change
const end = Math.max(start, this.lastImageLoadTimestamp, this.lastMutation?.timestamp ?? 0);

// report result to subscribers
this.next({
start,
end,
duration: end - start,
detail: {
navigationType,
didNetworkTimeOut,
lastVisibleChange: this.getLastVisibleChange(),
this.next(
{
start,
end,
duration: end - start,
detail: {
navigationType,
didNetworkTimeOut,
lastVisibleChange: this.getLastVisibleChange(),
},
},
});
observation
);
} else {
Logger.debug(
'VisuallyCompleteCalculator: Measurement discarded',
'::',
'index =',
navigationIndex
);

if (this.activeMeasurementIndex) {
this.error({
start,
end: performance.now(),
cancellationReason: CancellationReason.NEW_NAVIGATION,
navigationType,
lastVisibleChange: this.getLastVisibleChange(),
});
}
}

// cleanup
Expand All @@ -263,7 +298,7 @@ class VisuallyCompleteCalculator {
}
}

private next(measurement: Metric) {
private next(measurement: Metric, observation: Observation) {
if (measurement.end > Number.MAX_SAFE_INTEGER) {
Logger.warn(
'VisuallyCompleteCalculator.next()',
Expand All @@ -284,13 +319,13 @@ class VisuallyCompleteCalculator {
this.lastMutation?.timestamp ?? 0,
'::',
'index =',
this.activeMeasurementIndex
observation.index
);
Logger.info('TTVC:', measurement, '::', 'index =', this.activeMeasurementIndex);
Logger.info('TTVC:', measurement, '::', 'index =', observation.index);
this.successSubscribers.forEach((subscriber) => subscriber(measurement));
}

private error(error: CancellationError) {
private error(error: CancellationError, observation: Observation) {
Logger.debug(
'VisuallyCompleteCalculator.error()',
'::',
Expand All @@ -301,7 +336,7 @@ class VisuallyCompleteCalculator {
error.eventType || 'none',
'::',
'index =',
this.activeMeasurementIndex
observation.index
);
this.errorSubscribers.forEach((subscriber) => subscriber(error));
}
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/cancellation1/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h1 id="h1">Hello world!</h1>
<script async src="/stub.js?delay=750"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
TTVC.cancel('test_event_type');
TTVC.cancel(new Event('test_event_type'));
});
</script>
</body>
4 changes: 2 additions & 2 deletions test/e2e/cancellation2/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ <h1 id="h1">Hello world!</h1>
<script async src="/stub.js?delay=750"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
TTVC.cancel('test_event_type_1');
TTVC.cancel(new Event('test_event_type_1'));
setTimeout(() => {
TTVC.cancel('test_event_type_2');
TTVC.cancel(new Event('test_event_type_2'));
}, 0);
});
</script>
Expand Down
51 changes: 51 additions & 0 deletions test/e2e/spa6/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<head>
<script src="/dist/index.min.js"></script>
</head>

<body>
<h1 id="title"></h1>
<ul id="nav">
<li><a data-goto="/home" href="#">Home</a></li>
<li><a data-goto="/about" href="#">About</a></li>
</ul>

<script src="/analytics.js"></script>
<script type="module">
import {createHashHistory} from '/node_modules/history/history.production.min.js';
const history = createHashHistory();

// render the title based on current location
const render = async () => {
const title = document.getElementById('title');

// simulate data fetching required for route
title.innerText = 'Loading...';
await fetch('/api?delay=1000');

title.innerText = {
'/home': 'Home',
'/about': 'About',
}[history.location.pathname];
};

// set initial path to /home
history.push('/home');
render();

// set up link click handlers
const anchors = document.querySelectorAll('a');
anchors.forEach((anchor) => {
const url = anchor.dataset.goto;
anchor.addEventListener('click', (event) => {
event.preventDefault(0);
history.push(url);
});
});

// handle navigation
history.listen(() => {
document.documentElement.dispatchEvent(new Event('locationchange', {bubbles: true}));
render();
});
</script>
</body>
Loading
Loading