From bf5aee177c13de1f9573cdbf415c43019ce2f690 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:23:33 +0000 Subject: [PATCH 1/5] fix(plugin-autocapture-browser): reset lastScroll with the scroll tracker on page end The scroll tracker is zeroed at the end of a page view but lastScroll kept the previous page view's maxima, so the guard that suppresses no-op events compared a fresh scroll state against stale values. On SPA pages this emitted Viewport Content Updated events with an empty Element Exposed array. --- .../track-viewport-content-updated.ts | 20 ++++++---- .../viewport-content-updated.test.ts | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts index 4c26f48682..cdb8f0bace 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts @@ -53,6 +53,17 @@ 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 back to + // zero, 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(); + lastScroll.maxX = 0; + lastScroll.maxY = 0; + elementExposedForPage.clear(); + exposureTracker?.reset(); + }; + // If elements exposed is empty and max scroll is same as last event, don't track if ( currentElementExposed.size === 0 && @@ -60,9 +71,7 @@ export function fireViewportContentUpdated({ pageScrollMaxState.maxY === lastScroll.maxY ) { if (isPageEnd) { - scrollTracker.reset(); - elementExposedForPage.clear(); - exposureTracker?.reset(); + resetForNextPageView(); } return; } @@ -76,10 +85,7 @@ export function fireViewportContentUpdated({ currentElementExposed.clear(); if (isPageEnd) { - // Reset state for next page view - scrollTracker.reset(); - elementExposedForPage.clear(); - exposureTracker?.reset(); + resetForNextPageView(); } } diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts index 74a29f0622..19b17189fa 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts @@ -599,6 +599,45 @@ describe('fireViewportContentUpdated - early return when no changes', () => { ); }); + test('should reset lastScroll with the scroll tracker on page end so the next empty flush is deduplicated', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + const lastScroll: { maxX: undefined | number; maxY: undefined | number } = { maxX: undefined, maxY: undefined }; + // The scroll tracker is reset on page end, so it reports 0/0 for the next page view + const scrollState = { maxX: 100, maxY: 200 }; + const scrollTracker: ScrollTracker = { + getState: () => scrollState, + reset: jest.fn(() => { + scrollState.maxX = 0; + scrollState.maxY = 0; + }), + }; + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: true, + lastScroll, + }); + expect(trackSpy).toHaveBeenCalledTimes(1); + expect(lastScroll).toEqual({ maxX: 0, maxY: 0 }); + + // Next page view has nothing exposed and no scroll: nothing new to report + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: true, + lastScroll, + }); + expect(trackSpy).toHaveBeenCalledTimes(1); + }); + test('should mutate lastScroll so consecutive calls deduplicate correctly', () => { const currentElementExposed = new Set(); const elementExposedForPage = new Set(); From 7545e25acf5bd7fd876b922e5143e85849e2851a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:25:29 +0000 Subject: [PATCH 2/5] fix(plugin-autocapture-browser): re-expose elements still on screen after an exposure reset An IntersectionObserver only reports threshold crossings, so resetting the exposure state at the end of a page view meant nothing already in the viewport was ever reported again. On SPA sites, where a page end is triggered by a history update rather than an unload, this left Element Exposed empty for all above-the-fold content until the user scrolled it out of view and back. --- .../src/autocapture-plugin.ts | 8 ++ .../src/autocapture/track-exposure.ts | 5 ++ .../src/observables.ts | 26 +++++++ .../autocapture-plugin/track-exposure.test.ts | 15 ++++ .../test/observable.test.ts | 75 ++++++++++++++++++- 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index e71f5145ef..1e0d31ce78 100644 --- a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts +++ b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts @@ -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( @@ -220,6 +224,9 @@ export const autocapturePlugin = ( const exposureObservable = createExposureObservable( mutationObservable, (options as AutoCaptureOptionsWithDefaults).cssSelectorAllowlist, + (reobserve) => { + reobserveElementsInViewport = reobserve; + }, ); return { @@ -377,6 +384,7 @@ export const autocapturePlugin = ( onExposure: handleExposure, dataExtractor, exposureDuration: resolvedExposureDuration, + reobserve: () => reobserveElementsInViewport?.(), }); if (trackers.exposure) { subscriptions.push(trackers.exposure); diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts b/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts index 9b96ae56ca..686e4598e9 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts @@ -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(); @@ -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?.(); }, }; } diff --git a/packages/plugin-autocapture-browser/src/observables.ts b/packages/plugin-autocapture-browser/src/observables.ts index 833e8a9c32..8f9ca27fca 100644 --- a/packages/plugin-autocapture-browser/src/observables.ts +++ b/packages/plugin-autocapture-browser/src/observables.ts @@ -74,6 +74,9 @@ const createConsoleErrorObservable = (): Observable => { export const createExposureObservable = ( mutationObservable: Observable>, 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 => { return new Observable((observer) => { const globalScope = getGlobalScope(); @@ -84,9 +87,16 @@ export const createExposureObservable = ( }; } + const elementsInViewport = new Set(); + 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); }); }, @@ -122,7 +132,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(); }; diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts index 58f6464ae7..281c97cf6b 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts @@ -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(); + }); }); diff --git a/packages/plugin-autocapture-browser/test/observable.test.ts b/packages/plugin-autocapture-browser/test/observable.test.ts index b0c355a51c..ff0f0d3e1e 100644 --- a/packages/plugin-autocapture-browser/test/observable.test.ts +++ b/packages/plugin-autocapture-browser/test/observable.test.ts @@ -5,7 +5,7 @@ import { TimestampedEvent } from '../src/helpers'; describe('createExposureObservable', () => { let mutationObservable: Observable>; let mockMutationObserver: { subscribe: jest.Mock }; - let mockIntersectionObserver: { observe: jest.Mock; disconnect: jest.Mock }; + let mockIntersectionObserver: { observe: jest.Mock; unobserve: jest.Mock; disconnect: jest.Mock }; let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; let observers: ((value: TimestampedEvent) => void)[] = []; @@ -23,6 +23,7 @@ describe('createExposureObservable', () => { // Mock IntersectionObserver mockIntersectionObserver = { observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), }; @@ -207,6 +208,78 @@ describe('createExposureObservable', () => { expect(mockIntersectionObserver.disconnect).toHaveBeenCalled(); }); + test('should re-observe elements in the viewport when asked to reobserve', () => { + const inViewport = document.createElement('div'); + const outOfViewport = document.createElement('div'); + document.body.appendChild(inViewport); + document.body.appendChild(outOfViewport); + + let reobserve: (() => void) | undefined; + const exposureObservable = createExposureObservable(mutationObservable, ['div'], (fn) => { + reobserve = fn; + }); + exposureObservable.subscribe(() => { + return; + }); + + intersectionCallback([ + { isIntersecting: true, intersectionRatio: 1.0, target: inViewport } as unknown as IntersectionObserverEntry, + { isIntersecting: false, intersectionRatio: 0.5, target: outOfViewport } as unknown as IntersectionObserverEntry, + ]); + + mockIntersectionObserver.observe.mockClear(); + expect(reobserve).toBeDefined(); + reobserve?.(); + + // Only the element in the viewport needs to be reported again + expect(mockIntersectionObserver.unobserve).toHaveBeenCalledTimes(1); + expect(mockIntersectionObserver.unobserve).toHaveBeenCalledWith(inViewport); + expect(mockIntersectionObserver.observe).toHaveBeenCalledTimes(1); + expect(mockIntersectionObserver.observe).toHaveBeenCalledWith(inViewport); + + // Reobserving again is a no-op until the observer reports the element as visible again + mockIntersectionObserver.observe.mockClear(); + reobserve?.(); + expect(mockIntersectionObserver.observe).not.toHaveBeenCalled(); + }); + + test('should not re-observe elements in the viewport that left the DOM', () => { + const removed = document.createElement('div'); + document.body.appendChild(removed); + + let reobserve: (() => void) | undefined; + const exposureObservable = createExposureObservable(mutationObservable, ['div'], (fn) => { + reobserve = fn; + }); + exposureObservable.subscribe(() => { + return; + }); + + intersectionCallback([ + { isIntersecting: true, intersectionRatio: 1.0, target: removed } as unknown as IntersectionObserverEntry, + ]); + + removed.remove(); + mockIntersectionObserver.observe.mockClear(); + reobserve?.(); + + expect(mockIntersectionObserver.unobserve).toHaveBeenCalledWith(removed); + expect(mockIntersectionObserver.observe).not.toHaveBeenCalled(); + }); + + test('should clear the reobserve function on unsubscribe', () => { + const registerReobserve = jest.fn(); + const exposureObservable = createExposureObservable(mutationObservable, ['div'], registerReobserve); + const subscription = exposureObservable.subscribe(() => { + return; + }); + + expect(registerReobserve).toHaveBeenCalledWith(expect.any(Function)); + + subscription.unsubscribe(); + expect(registerReobserve).toHaveBeenLastCalledWith(undefined); + }); + test('should handle missing IntersectionObserver support gracefully', () => { const originalIntersectionObserver = (global as any).IntersectionObserver; (global as any).IntersectionObserver = undefined; From cf7719bf26d851b505fc8fe533110a9f2dd9e845 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:32:28 +0000 Subject: [PATCH 3/5] fix(plugin-autocapture-browser): only end the page view when the URL actually changes The Navigation API emits navigate for same-document history updates, including the history rewrites SPA frameworks do on hydration. Each one flushed a premature Viewport Content Updated event and reset the exposure state, so the page view that followed reported nothing. Page ends are now keyed off a URL change, pagehide is handled alongside beforeunload for browsers where beforeunload is unreliable, and the dedupe flag is only set for page ends so a buffer flush can no longer swallow the final event. --- .../src/autocapture-plugin.ts | 49 ++++++++--- .../src/constants.ts | 3 + .../default-event-tracking-advanced.test.ts | 86 ++++++++++++++++++- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index 1e0d31ce78..1e4a100b68 100644 --- a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts +++ b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts @@ -355,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, @@ -378,6 +380,20 @@ 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, @@ -390,14 +406,20 @@ export const autocapturePlugin = ( 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() }); @@ -406,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 @@ -429,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(); }, }); } diff --git a/packages/plugin-autocapture-browser/src/constants.ts b/packages/plugin-autocapture-browser/src/constants.ts index eff21f29fd..fc9ef8ef2b 100644 --- a/packages/plugin-autocapture-browser/src/constants.ts +++ b/packages/plugin-autocapture-browser/src/constants.ts @@ -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; diff --git a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts index 0d77d8a7d6..2c53c55054 100644 --- a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts +++ b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts @@ -138,6 +138,7 @@ describe('autoTrackingPlugin', () => { intersectionCallback = cb; return { observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), }; }); @@ -208,6 +209,7 @@ describe('autoTrackingPlugin', () => { intersectionCallback = cb; return { observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), }; }); @@ -273,6 +275,7 @@ describe('autoTrackingPlugin', () => { intersectionCallback = cb; return { observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), }; }); @@ -333,6 +336,7 @@ describe('autoTrackingPlugin', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).IntersectionObserver = jest.fn(() => ({ observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), })); @@ -1749,6 +1753,7 @@ describe('autoTrackingPlugin', () => { intersectionCallback = cb; return { observe: jest.fn(), + unobserve: jest.fn(), disconnect: jest.fn(), }; }); @@ -1804,7 +1809,9 @@ describe('autoTrackingPlugin', () => { }; await plugin?.setup?.(config as BrowserConfig, instance); - // history.pushState is proxied. + // history.pushState is proxied. window.location is a stub here, so the URL the browser + // would have updated is set by hand. + (window.location as any).href = 'https://www.test.com/new-page'; history.pushState({}, 'test', '/new-page'); expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); @@ -1816,10 +1823,26 @@ describe('autoTrackingPlugin', () => { window.dispatchEvent(new Event('scroll')); // Verify it can fire again (pageViewEndFired should be reset to false by the proxy) + (window.location as any).href = 'https://www.test.com/another-page'; history.pushState({}, 'test', '/another-page'); expect(track).toHaveBeenCalledTimes(2); }); + test('should not track [Amplitude] Viewport Content Updated when a history update leaves the URL unchanged', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + (window.location as any).href = 'https://www.test.com/page'; + await plugin?.setup?.(config as BrowserConfig, instance); + + // SPA frameworks rewrite history state without changing the URL, eg. during hydration + history.pushState({}, 'test', '/page'); + window.dispatchEvent(new Event('popstate')); + + expect(track).not.toHaveBeenCalled(); + }); + test('should track [Amplitude] Viewport Content Updated on popstate event', async () => { const config: Partial = { defaultTracking: false, @@ -1827,12 +1850,71 @@ describe('autoTrackingPlugin', () => { }; await plugin?.setup?.(config as BrowserConfig, instance); - // Simulate popstate event + // Simulate a back navigation to a different URL + (window.location as any).href = 'https://www.test.com/previous-page'; window.dispatchEvent(new Event('popstate')); expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); }); + test('should use the navigate event destination to decide whether the page view ended', async () => { + const handlers: ((event: Event) => void)[] = []; + (window.navigation as any) = { + addEventListener: (type: string, listener: (event: Event) => void) => { + if (type === 'navigate') { + handlers.push(listener); + } + }, + removeEventListener: jest.fn(), + }; + const navigateTo = (url: string) => { + handlers.forEach((handler) => handler({ type: 'navigate', destination: { url } } as unknown as Event)); + }; + + try { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + (window.location as any).href = 'https://www.test.com/page'; + await plugin?.setup?.(config as BrowserConfig, instance); + + // The Navigation API also emits navigate for history updates that keep the same URL + navigateTo('https://www.test.com/page'); + expect(track).not.toHaveBeenCalled(); + + navigateTo('https://www.test.com/other-page'); + expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); + } finally { + (window.navigation as any) = undefined; + } + }); + + test('should track [Amplitude] Viewport Content Updated on pagehide', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + window.dispatchEvent(new Event('pagehide')); + + expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); + }); + + test('should not track duplicate [Amplitude] Viewport Content Updated events for beforeunload and pagehide', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + window.dispatchEvent(new Event('pagehide')); + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledTimes(1); + }); + test('should flush Viewport Content Updated event when exposure buffer limit is reached', async () => { const config: Partial = { defaultTracking: false, From 106886c2b665c24c4b8a2ce14ec54e2d6e9f95a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:33:29 +0000 Subject: [PATCH 4/5] fix(plugin-autocapture-browser): observe the document element when the body does not exist yet When autocapture is initialized from the document head the mutation observer had nothing to attach to and was silently skipped, so no element added afterwards was ever observed for the rest of the page. --- .../src/observables.ts | 7 ++- .../test/observables-coverage.test.ts | 46 ++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/observables.ts b/packages/plugin-autocapture-browser/src/observables.ts index 8f9ca27fca..e7b53a49cf 100644 --- a/packages/plugin-autocapture-browser/src/observables.ts +++ b/packages/plugin-autocapture-browser/src/observables.ts @@ -9,8 +9,11 @@ export const createMutationObservable = (): Observable => { 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, diff --git a/packages/plugin-autocapture-browser/test/observables-coverage.test.ts b/packages/plugin-autocapture-browser/test/observables-coverage.test.ts index edc80aa341..96855dd061 100644 --- a/packages/plugin-autocapture-browser/test/observables-coverage.test.ts +++ b/packages/plugin-autocapture-browser/test/observables-coverage.test.ts @@ -65,26 +65,50 @@ describe('Observables Coverage', () => { }); describe('createMutationObservable', () => { - test('should handle missing document.body safely', () => { - // Save original body + const withoutBody = (run: () => void) => { const originalBody = document.body; - // Delete body Object.defineProperty(document, 'body', { value: null, configurable: true }); + try { + run(); + } finally { + Object.defineProperty(document, 'body', { value: originalBody, configurable: true }); + } + }; + test('should observe the document element when document.body does not exist yet', () => { + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); mockGetGlobalScope.mockReturnValue(window); // Ensure global scope is present - const observable = createMutationObservable(); - const subscription = observable.subscribe(() => { - return; + withoutBody(() => { + const subscription = createMutationObservable().subscribe(() => { + return; + }); + subscription.unsubscribe(); }); - subscription.unsubscribe(); + expect(observeSpy).toHaveBeenCalledWith(document.documentElement, expect.objectContaining({ subtree: true })); + }); + + test('should handle a document with neither body nor document element safely', () => { + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + const originalDocumentElement = document.documentElement; + Object.defineProperty(document, 'documentElement', { value: null, configurable: true }); - // Restore body - Object.defineProperty(document, 'body', { value: originalBody, configurable: true }); + try { + withoutBody(() => { + const subscription = createMutationObservable().subscribe(() => { + return; + }); + subscription.unsubscribe(); + }); + } finally { + Object.defineProperty(document, 'documentElement', { + value: originalDocumentElement, + configurable: true, + }); + } - // Verify it didn't throw and executed safely - expect(true).toBe(true); + expect(observeSpy).not.toHaveBeenCalled(); }); }); }); From 88e9e78fe714616c2fed0e7e047ab1c50b3a7a11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:57:38 +0000 Subject: [PATCH 5/5] fix(plugin-autocapture-browser): include the current scroll position in the scroll maxima The scroll tracker only learned from scroll events it witnessed, so landing on a URL with a fragment reported Max Page Y as one viewport: the browser jumps to the anchor before the subscription exists. The maxima now fold in the current position when read, and a page end rebaselines on where the page actually is rather than assuming the top. --- .../src/autocapture/track-scroll.ts | 15 +++++-- .../track-viewport-content-updated.ts | 9 +++-- .../autocapture-plugin/track-scroll.test.ts | 33 +++++++++++++++ .../viewport-content-updated.test.ts | 40 ++++++++++++++++++- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts b/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts index eac6b66dc1..f4bd9144c9 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts @@ -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; diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts index cdb8f0bace..9e5837a556 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts @@ -53,13 +53,14 @@ 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 back to - // zero, otherwise the next event is compared against maxima from the previous page view and + // 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(); - lastScroll.maxX = 0; - lastScroll.maxY = 0; + const scrollBaseline = scrollTracker.getState(); + lastScroll.maxX = scrollBaseline.maxX; + lastScroll.maxY = scrollBaseline.maxY; elementExposedForPage.clear(); exposureTracker?.reset(); }; diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts index 43df79254e..dd3ed66724 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts @@ -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, @@ -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); + }); }); diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts index 19b17189fa..6f68a9623e 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts @@ -603,7 +603,7 @@ describe('fireViewportContentUpdated - early return when no changes', () => { const currentElementExposed = new Set(['element-1']); const elementExposedForPage = new Set(['element-1']); const lastScroll: { maxX: undefined | number; maxY: undefined | number } = { maxX: undefined, maxY: undefined }; - // The scroll tracker is reset on page end, so it reports 0/0 for the next page view + // The scroll tracker rebaselines on page end, here on a page that scrolled back to the top const scrollState = { maxX: 100, maxY: 200 }; const scrollTracker: ScrollTracker = { getState: () => scrollState, @@ -638,6 +638,44 @@ describe('fireViewportContentUpdated - early return when no changes', () => { expect(trackSpy).toHaveBeenCalledTimes(1); }); + test('should take lastScroll from the scroll baseline the tracker keeps after a page end', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + const lastScroll: { maxX: undefined | number; maxY: undefined | number } = { maxX: undefined, maxY: undefined }; + // A page view that ends without scrolling back to the top: the next one starts at 1000 + const scrollState = { maxX: 0, maxY: 3000 }; + const scrollTracker: ScrollTracker = { + getState: () => scrollState, + reset: jest.fn(() => { + scrollState.maxY = 1000; + }), + }; + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: true, + lastScroll, + }); + + expect(lastScroll).toEqual({ maxX: 0, maxY: 1000 }); + + // Still sitting at 1000 with nothing exposed: nothing new to report + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: true, + lastScroll, + }); + expect(trackSpy).toHaveBeenCalledTimes(1); + }); + test('should mutate lastScroll so consecutive calls deduplicate correctly', () => { const currentElementExposed = new Set(); const elementExposedForPage = new Set();