Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 6 additions & 2 deletions packages/sdk/browser/__tests__/goals/GoalTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Copy link
Member Author

Choose a reason for hiding this comment

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

Consolidating adding event handlers resulted in extra parameters for options.

expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), undefined);
});

it('should not add click event listener if no click goals', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
118 changes: 118 additions & 0 deletions packages/sdk/browser/src/BrowserApi.ts
Original file line number Diff line number Diff line change
@@ -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<Element> | undefined {
if (isDocument()) {
return document.querySelectorAll(selector);
}
return undefined;
}
8 changes: 7 additions & 1 deletion packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -161,7 +163,7 @@ export class BrowserClient extends LDClientImpl implements LDClient {
event.data,
event.metricValue,
event.samplingRatio,
eventUrlTransformer(window.location.href),
eventUrlTransformer(getHref()),
),
},
);
Expand Down Expand Up @@ -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());
}
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/browser/src/BrowserStateDetector.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
3 changes: 2 additions & 1 deletion packages/sdk/browser/src/goals/GoalManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
}
}
Expand Down
23 changes: 15 additions & 8 deletions packages/sdk/browser/src/goals/GoalTracker.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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()),
),
);

Expand All @@ -80,21 +87,21 @@ 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);
}
}

/**
* Close the tracker which stops listening to any events.
*/
close() {
if (this.clickHandler) {
document.removeEventListener('click', this.clickHandler);
if (this.cleanup) {
this.cleanup();
}
}
}
10 changes: 6 additions & 4 deletions packages/sdk/browser/src/goals/LocationWatcher.ts
Original file line number Diff line number Diff line change
@@ -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
Expand 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;
Expand All @@ -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();
};
}

Expand Down
21 changes: 21 additions & 0 deletions packages/sdk/browser/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
TypeValidators,
} from '@launchdarkly/js-client-sdk-common';

const DEFAULT_FLUSH_INTERVAL_SECONDS = 2;
Copy link
Member Author

Choose a reason for hiding this comment

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

Realized this wasn't in as I was testing things out.


/**
* Initialization options for the LaunchDarkly browser SDK.
*/
Expand Down Expand Up @@ -35,12 +37,25 @@ export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMo
* This is equivalent to calling `client.setStreaming()` with the same value.
*/
streaming?: boolean;

/**
* Determines if the SDK responds to entering different visibility states to handle tasks such as
* flushing events.
*
* This is true by default. Generally speaking the SDK will be able to most reliably delivery
* events with this setting on.
*
* It may be useful to disable for environments where not all window/document objects are
* available, such as when running the SDK in a browser extension.
*/
automaticBackgroundHandling?: boolean;
}

export interface ValidatedOptions {
fetchGoals: boolean;
eventUrlTransformer: (url: string) => string;
streaming?: boolean;
automaticBackgroundHandling?: boolean;
}

const optDefaults = {
Expand All @@ -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];
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/browser/src/platform/BrowserCrypto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading
Loading