diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index e71f5145ef..1e4a100b68 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 { @@ -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, @@ -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() }); @@ -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 @@ -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(); }, }); } 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/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 4c26f48682..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,6 +53,18 @@ 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 && @@ -60,9 +72,7 @@ export function fireViewportContentUpdated({ pageScrollMaxState.maxY === lastScroll.maxY ) { if (isPageEnd) { - scrollTracker.reset(); - elementExposedForPage.clear(); - exposureTracker?.reset(); + resetForNextPageView(); } return; } @@ -76,10 +86,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/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/src/observables.ts b/packages/plugin-autocapture-browser/src/observables.ts index 833e8a9c32..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, @@ -74,6 +77,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 +90,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 +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(); }; 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/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 74a29f0622..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 @@ -599,6 +599,83 @@ 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 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, + 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 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(); 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, 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; 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(); }); }); });