diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts index 23491abf2..fbad973f4 100644 --- a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts @@ -78,7 +78,7 @@ it('should add click event listener for click goals', () => { new GoalTracker(goals, mockOnEvent); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), undefined); }); it('should not add click event listener if no click goals', () => { @@ -175,7 +175,11 @@ it('should remove click event listener on close', () => { const tracker = new GoalTracker(goals, mockOnEvent); tracker.close(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(document.removeEventListener).toHaveBeenCalledWith( + 'click', + expect.any(Function), + undefined, + ); }); it('should trigger the click goal for parent elements which match the selector', () => { diff --git a/packages/sdk/browser/src/BrowserApi.ts b/packages/sdk/browser/src/BrowserApi.ts new file mode 100644 index 000000000..a9c233b7a --- /dev/null +++ b/packages/sdk/browser/src/BrowserApi.ts @@ -0,0 +1,118 @@ +/** + * All access to browser specific APIs should be limited to this file. + * Care should be taken to ensure that any given method will work in the service worker API. So if + * something isn't available in the service worker API attempt to provide reasonable defaults. + */ + +export function isDocument() { + return typeof document !== undefined; +} + +export function isWindow() { + return typeof window !== undefined; +} + +/** + * Register an event handler on the document. If there is no document, such as when running in + * a service worker, then no operation is performed. + * + * @param type The event type to register a handler for. + * @param listener The handler to register. + * @param options Event registration options. + * @returns a function which unregisters the handler. + */ +export function addDocumentEventListener( + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): () => void { + if (isDocument()) { + document.addEventListener(type, listener, options); + return () => { + document.removeEventListener(type, listener, options); + }; + } + // No document, so no need to unregister anything. + return () => {}; +} + +/** + * Register an event handler on the window. If there is no window, such as when running in + * a service worker, then no operation is performed. + * + * @param type The event type to register a handler for. + * @param listener The handler to register. + * @param options Event registration options. + * @returns a function which unregisters the handler. + */ +export function addWindowEventListener( + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): () => void { + if (isDocument()) { + window.addEventListener(type, listener, options); + return () => { + window.removeEventListener(type, listener, options); + }; + } + // No document, so no need to unregister anything. + return () => {}; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getHref(): string { + if (isWindow()) { + return window.location.href; + } + return ''; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getLocationSearch(): string { + if (isWindow()) { + return window.location.search; + } + return ''; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getLocationHash(): string { + if (isWindow()) { + return window.location.hash; + } + return ''; +} + +export function getCrypto(): Crypto { + if (typeof crypto !== undefined) { + return crypto; + } + // This would indicate running in an environment that doesn't have window.crypto or self.crypto. + throw Error('Access to a web crypto API is required'); +} + +/** + * Get the visibility state. For non-documents this will always be 'invisible'. + * + * @returns The document visibility. + */ +export function getVisibility(): string { + if (isDocument()) { + return document.visibilityState; + } + return 'visibile'; +} + +export function querySelectorAll(selector: string): NodeListOf | undefined { + if (isDocument()) { + return document.querySelectorAll(selector); + } + return undefined; +} diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index cef2729ab..43ec5eb88 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -15,8 +15,10 @@ import { Platform, } from '@launchdarkly/js-client-sdk-common'; +import { getHref } from './BrowserApi'; import BrowserDataManager from './BrowserDataManager'; import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; +import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; @@ -161,7 +163,7 @@ export class BrowserClient extends LDClientImpl implements LDClient { event.data, event.metricValue, event.samplingRatio, - eventUrlTransformer(window.location.href), + eventUrlTransformer(getHref()), ), }, ); @@ -211,6 +213,10 @@ export class BrowserClient extends LDClientImpl implements LDClient { // which emits the event, and assign its promise to a member. The "waitForGoalsReady" function // would return that promise. this.goalManager.initialize(); + + if (validatedBrowserOptions.automaticBackgroundHandling) { + registerStateDetection(() => this.flush()); + } } } diff --git a/packages/sdk/browser/src/BrowserStateDetector.ts b/packages/sdk/browser/src/BrowserStateDetector.ts new file mode 100644 index 000000000..f554a8420 --- /dev/null +++ b/packages/sdk/browser/src/BrowserStateDetector.ts @@ -0,0 +1,27 @@ +import { addDocumentEventListener, addWindowEventListener, getVisibility } from './BrowserApi'; + +export function registerStateDetection(requestFlush: () => void): () => void { + // When the visibility of the page changes to hidden we want to flush any pending events. + // + // This is handled with visibility, instead of beforeunload/unload + // because those events are not compatible with the bfcache and are unlikely + // to be called in many situations. For more information see: https://developer.chrome.com/blog/page-lifecycle-api/ + // + // Redundancy is included by using both the visibilitychange handler as well as + // pagehide, because different browsers, and versions have different bugs with each. + // This also may provide more opportunity for the events to get flushed. + // + const handleVisibilityChange = () => { + if (getVisibility() === 'hidden') { + requestFlush(); + } + }; + + const removeDocListener = addDocumentEventListener('visibilitychange', handleVisibilityChange); + const removeWindowListener = addWindowEventListener('pagehide', requestFlush); + + return () => { + removeDocListener(); + removeWindowListener(); + }; +} diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts index 50932dd0c..1862cfaa8 100644 --- a/packages/sdk/browser/src/goals/GoalManager.ts +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -1,5 +1,6 @@ import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; +import { getHref } from '../BrowserApi'; import { Goal } from './Goals'; import GoalTracker from './GoalTracker'; import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher'; @@ -47,7 +48,7 @@ export default class GoalManager { this.tracker?.close(); if (this.goals && this.goals.length) { this.tracker = new GoalTracker(this.goals, (goal) => { - this.reportGoal(window.location.href, goal); + this.reportGoal(getHref(), goal); }); } } diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts index 71e7d555f..268d0b68a 100644 --- a/packages/sdk/browser/src/goals/GoalTracker.ts +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -1,5 +1,12 @@ import escapeStringRegexp from 'escape-string-regexp'; +import { + addDocumentEventListener, + getHref, + getLocationHash, + getLocationSearch, + querySelectorAll, +} from '../BrowserApi'; import { ClickGoal, Goal, Matcher } from './Goals'; type EventHandler = (goal: Goal) => void; @@ -37,11 +44,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { clickGoals.forEach((goal) => { let target: Node | null = event.target as Node; const { selector } = goal; - const elements = document.querySelectorAll(selector); + const elements = querySelectorAll(selector); // Traverse from the target of the event up the page hierarchy. // If there are no element that match the selector, then no need to check anything. - while (target && elements.length) { + while (target && elements?.length) { // The elements are a NodeList, so it doesn't have the array functions. For performance we // do not convert it to an array. for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { @@ -64,11 +71,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { * Tracks the goals on an individual "page" (combination of route, query params, and hash). */ export default class GoalTracker { - private clickHandler?: (event: Event) => void; + private cleanup?: () => void; constructor(goals: Goal[], onEvent: EventHandler) { const goalsMatchingUrl = goals.filter((goal) => goal.urls?.some((matcher) => - matchesUrl(matcher, window.location.href, window.location.search, window.location.hash), + matchesUrl(matcher, getHref(), getLocationSearch(), getLocationHash()), ), ); @@ -80,12 +87,12 @@ export default class GoalTracker { if (clickGoals.length) { // Click handler is not a member function in order to avoid having to bind it for the event // handler and then track a reference to that bound handler. - this.clickHandler = (event: Event) => { + const clickHandler = (event: Event) => { findGoalsForClick(event, clickGoals).forEach((clickGoal) => { onEvent(clickGoal); }); }; - document.addEventListener('click', this.clickHandler); + this.cleanup = addDocumentEventListener('click', clickHandler); } } @@ -93,8 +100,6 @@ export default class GoalTracker { * Close the tracker which stops listening to any events. */ close() { - if (this.clickHandler) { - document.removeEventListener('click', this.clickHandler); - } + this.cleanup?.(); } } diff --git a/packages/sdk/browser/src/goals/LocationWatcher.ts b/packages/sdk/browser/src/goals/LocationWatcher.ts index 7b6c5e3de..75aceb306 100644 --- a/packages/sdk/browser/src/goals/LocationWatcher.ts +++ b/packages/sdk/browser/src/goals/LocationWatcher.ts @@ -1,3 +1,5 @@ +import { addWindowEventListener, getHref } from '../BrowserApi'; + export const LOCATION_WATCHER_INTERVAL_MS = 300; // Using any for the timer handle because the type is not the same for all @@ -24,9 +26,9 @@ export class DefaultLocationWatcher { * @param callback Callback that is executed whenever a URL change is detected. */ constructor(callback: () => void) { - this.previousLocation = window.location.href; + this.previousLocation = getHref(); const checkUrl = () => { - const currentLocation = window.location.href; + const currentLocation = getHref(); if (currentLocation !== this.previousLocation) { this.previousLocation = currentLocation; @@ -41,10 +43,10 @@ export class DefaultLocationWatcher { */ this.watcherHandle = setInterval(checkUrl, LOCATION_WATCHER_INTERVAL_MS); - window.addEventListener('popstate', checkUrl); + const removeListener = addWindowEventListener('popstate', checkUrl); this.cleanupListeners = () => { - window.removeEventListener('popstate', checkUrl); + removeListener(); }; } diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index 7a6acce14..3cb4e3568 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -6,6 +6,8 @@ import { TypeValidators, } from '@launchdarkly/js-client-sdk-common'; +const DEFAULT_FLUSH_INTERVAL_SECONDS = 2; + /** * Initialization options for the LaunchDarkly browser SDK. */ @@ -35,12 +37,25 @@ export interface BrowserOptions extends Omit string; streaming?: boolean; + automaticBackgroundHandling?: boolean; } const optDefaults = { @@ -66,8 +81,14 @@ export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { return baseOptions; } +function applyBrowserDefaults(opts: BrowserOptions) { + // eslint-disable-next-line no-param-reassign + opts.flushInterval ??= DEFAULT_FLUSH_INTERVAL_SECONDS; +} + export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions { const output: ValidatedOptions = { ...optDefaults }; + applyBrowserDefaults(output); Object.entries(validators).forEach((entry) => { const [key, validator] = entry as [keyof BrowserOptions, TypeValidator]; diff --git a/packages/sdk/browser/src/platform/BrowserCrypto.ts b/packages/sdk/browser/src/platform/BrowserCrypto.ts index e241bc50a..29695655c 100644 --- a/packages/sdk/browser/src/platform/BrowserCrypto.ts +++ b/packages/sdk/browser/src/platform/BrowserCrypto.ts @@ -1,11 +1,12 @@ import { Crypto } from '@launchdarkly/js-client-sdk-common'; +import { getCrypto } from '../BrowserApi'; import BrowserHasher from './BrowserHasher'; import randomUuidV4 from './randomUuidV4'; export default class BrowserCrypto implements Crypto { createHash(algorithm: string): BrowserHasher { - return new BrowserHasher(window.crypto, algorithm); + return new BrowserHasher(getCrypto(), algorithm); } randomUUID(): string { diff --git a/packages/shared/common/__tests__/internal/events/EventSender.test.ts b/packages/shared/common/__tests__/internal/events/EventSender.test.ts index 2e4c3f3dd..63a6130dc 100644 --- a/packages/shared/common/__tests__/internal/events/EventSender.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSender.test.ts @@ -135,6 +135,7 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData1), headers: analyticsHeaders(uuid), method: 'POST', + keepalive: true, }); }); @@ -151,6 +152,7 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData1), headers: analyticsHeaders(uuid), method: 'POST', + keepalive: true, }); expect(mockFetch).toHaveBeenNthCalledWith( 2, @@ -159,6 +161,7 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData2), headers: diagnosticHeaders, method: 'POST', + keepalive: true, }, ); }); diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index 219d775f4..8b0438d20 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -76,6 +76,11 @@ export interface Options { method?: string; body?: string; timeout?: number; + /** + * For use in browser environments. Platform support will be best effort for this field. + * https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#keepalive + */ + keepalive?: boolean; } export interface EventSourceCapabilities { diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts index 3d11a14b3..3bb585024 100644 --- a/packages/shared/common/src/internal/events/EventSender.ts +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -65,6 +65,9 @@ export default class EventSender implements LDEventSender { headers, body: JSON.stringify(events), method: 'POST', + // When sending events from browser environments the request should be completed even + // if the user is navigating away from the page. + keepalive: true, }); const serverDate = Date.parse(resHeaders.get('date') || ''); diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index c88c608eb..6c752a72f 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -113,9 +113,9 @@ export interface LDOptions { eventsUri?: string; /** - * Controls how often the SDK flushes events. + * The interval in between flushes of the analytics events queue, in seconds. * - * @defaultValue 30s. + * @defaultValue 2s for browser implementations 30s for others. */ flushInterval?: number;