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
57 changes: 44 additions & 13 deletions packages/plugin-autocapture-browser/src/autocapture-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export const autocapturePlugin = (

let beforeUnloadCleanup: () => void;

// Re-emits exposure entries for the elements currently in the viewport. Set when the exposure
// observable is subscribed to.
let reobserveElementsInViewport: (() => void) | undefined;

const createObservables = (): AllWindowObservables => {
const clickObservable = multicast(
createClickObservable().map(
Expand Down Expand Up @@ -220,6 +224,9 @@ export const autocapturePlugin = (
const exposureObservable = createExposureObservable(
mutationObservable,
(options as AutoCaptureOptionsWithDefaults).cssSelectorAllowlist,
(reobserve) => {
reobserveElementsInViewport = reobserve;
},
);

return {
Expand Down Expand Up @@ -348,14 +355,16 @@ export const autocapturePlugin = (
const globalScope = getGlobalScope();

const handleViewportContentUpdated = (isPageEnd: boolean) => {
if (isPageEnd && pageViewEndFired) {
return;
if (isPageEnd) {
if (pageViewEndFired) {
return;
}
pageViewEndFired = true;
setTimeout(() => {
pageViewEndFired = false;
}, constants.PAGE_VIEW_END_DEDUPE_MS);
}
setTimeout(() => {
pageViewEndFired = false;
}, 100);

pageViewEndFired = true;
fireViewportContentUpdated({
amplitude,
scrollTracker,
Expand All @@ -371,25 +380,46 @@ export const autocapturePlugin = (
onExposure(elementPath, elementExposedForPage, currentElementExposed, handleViewportContentUpdated);
};

let currentUrl = window.location.href;

// Same-document history updates that leave the URL unchanged are not a new page view. SPA
// frameworks emit them routinely (eg. rewriting history state during hydration), and treating
// them as a page end would flush a premature event and reset the exposure state.
const handleNavigation = (destinationUrl?: string) => {
const nextUrl = destinationUrl ?? window.location.href;
if (nextUrl === currentUrl) {
return;
}
currentUrl = nextUrl;
handleViewportContentUpdated(true);
};

if (isViewportContentUpdatedEnabled) {
trackers.exposure = trackExposure({
allObservables,
onExposure: handleExposure,
dataExtractor,
exposureDuration: resolvedExposureDuration,
reobserve: () => reobserveElementsInViewport?.(),
});
if (trackers.exposure) {
subscriptions.push(trackers.exposure);
}

const beforeUnloadHandler = () => {
const pageEndHandler = () => {
handleViewportContentUpdated(true);
};
// pagehide covers the cases where beforeunload is unreliable, most notably mobile browsers
// and pages entering the back/forward cache. Both firing is deduplicated by pageViewEndFired.
/* istanbul ignore next */
globalScope?.addEventListener('beforeunload', pageEndHandler);
/* istanbul ignore next */
globalScope?.addEventListener('beforeunload', beforeUnloadHandler);
globalScope?.addEventListener('pagehide', pageEndHandler);
beforeUnloadCleanup = () => {
/* istanbul ignore next */
globalScope?.removeEventListener('beforeunload', beforeUnloadHandler);
globalScope?.removeEventListener('beforeunload', pageEndHandler);
/* istanbul ignore next */
globalScope?.removeEventListener('pagehide', pageEndHandler);
};
// Ensure cleanup on teardown as well
subscriptions.push({ unsubscribe: () => beforeUnloadCleanup() });
Expand All @@ -398,13 +428,14 @@ export const autocapturePlugin = (
const navigateObservable = allObservables[ObservablesEnum.NavigateObservable];
if (navigateObservable) {
subscriptions.push(
navigateObservable.subscribe(() => {
handleViewportContentUpdated(true);
navigateObservable.subscribe((navigateEvent) => {
/* istanbul ignore next */
handleNavigation(navigateEvent?.event?.destination?.url);
}),
);
} else if (globalScope) {
const popstateHandler = () => {
handleViewportContentUpdated(true);
handleNavigation();
};
/* istanbul ignore next */
// Fallback for SPA tracking when Navigation API is not available
Expand All @@ -421,7 +452,7 @@ export const autocapturePlugin = (
globalScope.history.pushState = new Proxy(originalPushState, {
apply: (target, thisArg, [state, unused, url]) => {
target.apply(thisArg, [state, unused, url]);
handleViewportContentUpdated(true);
handleNavigation();
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export function trackExposure({
onExposure,
dataExtractor,
exposureDuration = DEFAULT_EXPOSURE_DURATION,
reobserve,
}: {
allObservables: AllWindowObservables;
onExposure: (elementPath: string) => void;
dataExtractor: DataExtractor;
exposureDuration?: number;
reobserve?: () => void;
}) {
// Track which elements have been marked as exposed (per-element state)
const exposureMap = new Map<Element, boolean>();
Expand Down Expand Up @@ -65,6 +67,9 @@ export function trackExposure({
});
exposureTimerMap.clear();
exposureMap.clear();
// Elements that stay on screen across the reset have to be reported again, otherwise
// nothing above the fold is ever exposed for the next page view.
reobserve?.();
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,30 @@ export function trackScroll({
const { scrollObservable } = allObservables;
const state: ScrollState = { maxX: 0, maxY: 0 };

const scrollSubscription = scrollObservable.subscribe(() => {
// Update page-level max positions for Page View End event (never resets during page lifetime)
const recordCurrentPosition = () => {
const globalScope = getGlobalScope();
/* istanbul ignore next */
const currentX = Math.floor(globalScope?.scrollX ?? globalScope?.pageXOffset ?? 0);
/* istanbul ignore next */
const currentY = Math.floor(globalScope?.scrollY ?? globalScope?.pageYOffset ?? 0);

// Update page-level max positions for Page View End event (never resets during page lifetime)
state.maxX = Math.max(state.maxX, currentX);
state.maxY = Math.max(state.maxY, currentY);
});
};

const scrollSubscription = scrollObservable.subscribe(recordCurrentPosition);

return {
unsubscribe: () => {
scrollSubscription.unsubscribe();
},
getState: () => state,
// Folding in the current position covers scrolling this subscription never saw, such as the
// browser jumping to a URL fragment on load or restoring the position on a reload.
getState: () => {
recordCurrentPosition();
return state;
},
reset: () => {
state.maxX = 0;
state.maxY = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,26 @@ export function fireViewportContentUpdated({
eventProperties[constants.AMPLITUDE_EVENT_PROP_PAGE_VIEW_ID] = pageViewId;
}

// Reset state for the next page view. lastScroll has to follow the scroll tracker's new
// baseline, otherwise the next event is compared against maxima from the previous page view and
// an event with no exposed elements looks like a scroll change.
const resetForNextPageView = () => {
scrollTracker.reset();
const scrollBaseline = scrollTracker.getState();
lastScroll.maxX = scrollBaseline.maxX;
lastScroll.maxY = scrollBaseline.maxY;
elementExposedForPage.clear();
exposureTracker?.reset();
};

// If elements exposed is empty and max scroll is same as last event, don't track
if (
currentElementExposed.size === 0 &&
pageScrollMaxState.maxX === lastScroll.maxX &&
pageScrollMaxState.maxY === lastScroll.maxY
) {
if (isPageEnd) {
scrollTracker.reset();
elementExposedForPage.clear();
exposureTracker?.reset();
resetForNextPageView();
}
return;
}
Expand All @@ -76,10 +86,7 @@ export function fireViewportContentUpdated({
currentElementExposed.clear();

if (isPageEnd) {
// Reset state for next page view
scrollTracker.reset();
elementExposedForPage.clear();
exposureTracker?.reset();
resetForNextPageView();
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-autocapture-browser/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ export const MAX_ATTRIBUTE_LENGTH = 128;
export const PAGE_VIEW_SESSION_STORAGE_KEY = 'AMP_PAGE_VIEW';

export const MAX_ELEMENT_EXPOSED_STR_LENGTH = 18_000;

// Window during which a second page end (eg. a navigation followed by an unload) is ignored
export const PAGE_VIEW_END_DEDUPE_MS = 100;
33 changes: 31 additions & 2 deletions packages/plugin-autocapture-browser/src/observables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export const createMutationObservable = (): Observable<MutationRecord[]> => {
const mutationObserver = new MutationObserver((mutations) => {
observer.next(mutations);
});
if (document.body) {
mutationObserver.observe(document.body, {
// Autocapture can be initialized from the document head, before the body exists. Falling back
// to the document element keeps the body and everything parsed into it observed.
const target = document.body ?? document.documentElement;
if (target) {
mutationObserver.observe(target, {
childList: true,
attributes: true,
characterData: true,
Expand Down Expand Up @@ -74,6 +77,9 @@ const createConsoleErrorObservable = (): Observable<BrowserErrorEvent> => {
export const createExposureObservable = (
mutationObservable: Observable<TimestampedEvent<MutationRecord[]>>,
selectorAllowlist: string[],
// Receives a function that re-emits entries for the elements currently in the viewport, or
// undefined once the observable is torn down.
registerReobserve?: (reobserve: (() => void) | undefined) => void,
): Observable<Event> => {
return new Observable<Event>((observer) => {
const globalScope = getGlobalScope();
Expand All @@ -84,9 +90,16 @@ export const createExposureObservable = (
};
}

const elementsInViewport = new Set<Element>();

const intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
elementsInViewport.add(entry.target);
} else {
elementsInViewport.delete(entry.target);
}
observer.next(entry as unknown as Event);
});
},
Expand Down Expand Up @@ -122,7 +135,23 @@ export const createExposureObservable = (
),
);

// An IntersectionObserver only reports threshold crossings, so elements that are already in
// the viewport are never reported again. Whoever resets the exposure state (eg. at the end of
// a page view) needs the elements on screen to be reported once more for the new page view,
// which re-observing does.
registerReobserve?.(() => {
const elements = Array.from(elementsInViewport);
elementsInViewport.clear();
elements.forEach((element) => {
intersectionObserver.unobserve(element);
if (element.isConnected) {
intersectionObserver.observe(element);
}
});
});

return () => {
registerReobserve?.(undefined);
mutationSubscription.unsubscribe();
intersectionObserver.disconnect();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,19 @@ describe('trackExposure', () => {
jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5);
expect(onExposure).toHaveBeenCalledWith('div#reset-div-2');
});

test('should ask for elements in the viewport to be reported again on reset', () => {
const reobserve = jest.fn();
const tracker = trackExposure({
allObservables,
onExposure,
dataExtractor: new DataExtractor({}),
reobserve,
});

tracker.reset();
expect(reobserve).toHaveBeenCalledTimes(1);

tracker.unsubscribe();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ describe('trackScroll', () => {
expect(tracker.getState().maxY).toBe(0);
});

test('should include the current position even when no scroll event was seen', () => {
// The browser jumps to a URL fragment on load, before this subscription exists
setScroll(0, 3106);

const tracker = trackScroll({
amplitude,
allObservables,
});
unsubscribe = tracker.unsubscribe;

expect(tracker.getState().maxY).toBe(3106);
});

test('should reset state', () => {
const tracker = trackScroll({
amplitude,
Expand All @@ -125,8 +138,28 @@ describe('trackScroll', () => {
expect(tracker.getState().maxX).toBe(100);
expect(tracker.getState().maxY).toBe(200);

// Reset drops what the previous page view reached and rebaselines on the current position
setScroll(0, 0);
tracker.reset();
expect(tracker.getState().maxX).toBe(0);
expect(tracker.getState().maxY).toBe(0);
});

test('should rebaseline on the current position when reset without scrolling back to the top', () => {
const tracker = trackScroll({
amplitude,
allObservables,
});
unsubscribe = tracker.unsubscribe;

setScroll(0, 3000);
triggerScroll();
setScroll(0, 1000);
triggerScroll();
expect(tracker.getState().maxY).toBe(3000);

// A SPA route change that leaves the page where it was starts the next page view at 1000
tracker.reset();
expect(tracker.getState().maxY).toBe(1000);
});
});
Loading
Loading