Skip to content

Commit 819a311

Browse files
authored
feat: Add visibility handling to allow proactive event flushing. (#607)
Additionally use the `keep-alive` setting of fetch to ensure delivery of events even when the page may be closing. Also better encapsulating browser APIs to ensure that the SDK can operate smoothly in service workers and browser extensions. Also set browser appropriate flush interval. Currently no testing for the state detector because of: jestjs/jest#10025 I think we need to switch to a babel based Jest setup to get things working better. So far Jest has not been a pleasant experience for ESM.
1 parent 04d347b commit 819a311

File tree

13 files changed

+216
-20
lines changed

13 files changed

+216
-20
lines changed

packages/sdk/browser/__tests__/goals/GoalTracker.test.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ it('should add click event listener for click goals', () => {
7878

7979
new GoalTracker(goals, mockOnEvent);
8080

81-
expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
81+
expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), undefined);
8282
});
8383

8484
it('should not add click event listener if no click goals', () => {
@@ -175,7 +175,11 @@ it('should remove click event listener on close', () => {
175175
const tracker = new GoalTracker(goals, mockOnEvent);
176176
tracker.close();
177177

178-
expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
178+
expect(document.removeEventListener).toHaveBeenCalledWith(
179+
'click',
180+
expect.any(Function),
181+
undefined,
182+
);
179183
});
180184

181185
it('should trigger the click goal for parent elements which match the selector', () => {
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* All access to browser specific APIs should be limited to this file.
3+
* Care should be taken to ensure that any given method will work in the service worker API. So if
4+
* something isn't available in the service worker API attempt to provide reasonable defaults.
5+
*/
6+
7+
export function isDocument() {
8+
return typeof document !== undefined;
9+
}
10+
11+
export function isWindow() {
12+
return typeof window !== undefined;
13+
}
14+
15+
/**
16+
* Register an event handler on the document. If there is no document, such as when running in
17+
* a service worker, then no operation is performed.
18+
*
19+
* @param type The event type to register a handler for.
20+
* @param listener The handler to register.
21+
* @param options Event registration options.
22+
* @returns a function which unregisters the handler.
23+
*/
24+
export function addDocumentEventListener(
25+
type: string,
26+
listener: (this: Document, ev: Event) => any,
27+
options?: boolean | AddEventListenerOptions,
28+
): () => void {
29+
if (isDocument()) {
30+
document.addEventListener(type, listener, options);
31+
return () => {
32+
document.removeEventListener(type, listener, options);
33+
};
34+
}
35+
// No document, so no need to unregister anything.
36+
return () => {};
37+
}
38+
39+
/**
40+
* Register an event handler on the window. If there is no window, such as when running in
41+
* a service worker, then no operation is performed.
42+
*
43+
* @param type The event type to register a handler for.
44+
* @param listener The handler to register.
45+
* @param options Event registration options.
46+
* @returns a function which unregisters the handler.
47+
*/
48+
export function addWindowEventListener(
49+
type: string,
50+
listener: (this: Document, ev: Event) => any,
51+
options?: boolean | AddEventListenerOptions,
52+
): () => void {
53+
if (isDocument()) {
54+
window.addEventListener(type, listener, options);
55+
return () => {
56+
window.removeEventListener(type, listener, options);
57+
};
58+
}
59+
// No document, so no need to unregister anything.
60+
return () => {};
61+
}
62+
63+
/**
64+
* For non-window code this will always be an empty string.
65+
*/
66+
export function getHref(): string {
67+
if (isWindow()) {
68+
return window.location.href;
69+
}
70+
return '';
71+
}
72+
73+
/**
74+
* For non-window code this will always be an empty string.
75+
*/
76+
export function getLocationSearch(): string {
77+
if (isWindow()) {
78+
return window.location.search;
79+
}
80+
return '';
81+
}
82+
83+
/**
84+
* For non-window code this will always be an empty string.
85+
*/
86+
export function getLocationHash(): string {
87+
if (isWindow()) {
88+
return window.location.hash;
89+
}
90+
return '';
91+
}
92+
93+
export function getCrypto(): Crypto {
94+
if (typeof crypto !== undefined) {
95+
return crypto;
96+
}
97+
// This would indicate running in an environment that doesn't have window.crypto or self.crypto.
98+
throw Error('Access to a web crypto API is required');
99+
}
100+
101+
/**
102+
* Get the visibility state. For non-documents this will always be 'invisible'.
103+
*
104+
* @returns The document visibility.
105+
*/
106+
export function getVisibility(): string {
107+
if (isDocument()) {
108+
return document.visibilityState;
109+
}
110+
return 'visibile';
111+
}
112+
113+
export function querySelectorAll(selector: string): NodeListOf<Element> | undefined {
114+
if (isDocument()) {
115+
return document.querySelectorAll(selector);
116+
}
117+
return undefined;
118+
}

packages/sdk/browser/src/BrowserClient.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import {
1515
Platform,
1616
} from '@launchdarkly/js-client-sdk-common';
1717

18+
import { getHref } from './BrowserApi';
1819
import BrowserDataManager from './BrowserDataManager';
1920
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
21+
import { registerStateDetection } from './BrowserStateDetector';
2022
import GoalManager from './goals/GoalManager';
2123
import { Goal, isClick } from './goals/Goals';
2224
import validateOptions, { BrowserOptions, filterToBaseOptions } from './options';
@@ -161,7 +163,7 @@ export class BrowserClient extends LDClientImpl implements LDClient {
161163
event.data,
162164
event.metricValue,
163165
event.samplingRatio,
164-
eventUrlTransformer(window.location.href),
166+
eventUrlTransformer(getHref()),
165167
),
166168
},
167169
);
@@ -211,6 +213,10 @@ export class BrowserClient extends LDClientImpl implements LDClient {
211213
// which emits the event, and assign its promise to a member. The "waitForGoalsReady" function
212214
// would return that promise.
213215
this.goalManager.initialize();
216+
217+
if (validatedBrowserOptions.automaticBackgroundHandling) {
218+
registerStateDetection(() => this.flush());
219+
}
214220
}
215221
}
216222

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { addDocumentEventListener, addWindowEventListener, getVisibility } from './BrowserApi';
2+
3+
export function registerStateDetection(requestFlush: () => void): () => void {
4+
// When the visibility of the page changes to hidden we want to flush any pending events.
5+
//
6+
// This is handled with visibility, instead of beforeunload/unload
7+
// because those events are not compatible with the bfcache and are unlikely
8+
// to be called in many situations. For more information see: https://developer.chrome.com/blog/page-lifecycle-api/
9+
//
10+
// Redundancy is included by using both the visibilitychange handler as well as
11+
// pagehide, because different browsers, and versions have different bugs with each.
12+
// This also may provide more opportunity for the events to get flushed.
13+
//
14+
const handleVisibilityChange = () => {
15+
if (getVisibility() === 'hidden') {
16+
requestFlush();
17+
}
18+
};
19+
20+
const removeDocListener = addDocumentEventListener('visibilitychange', handleVisibilityChange);
21+
const removeWindowListener = addWindowEventListener('pagehide', requestFlush);
22+
23+
return () => {
24+
removeDocListener();
25+
removeWindowListener();
26+
};
27+
}

packages/sdk/browser/src/goals/GoalManager.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common';
22

3+
import { getHref } from '../BrowserApi';
34
import { Goal } from './Goals';
45
import GoalTracker from './GoalTracker';
56
import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher';
@@ -47,7 +48,7 @@ export default class GoalManager {
4748
this.tracker?.close();
4849
if (this.goals && this.goals.length) {
4950
this.tracker = new GoalTracker(this.goals, (goal) => {
50-
this.reportGoal(window.location.href, goal);
51+
this.reportGoal(getHref(), goal);
5152
});
5253
}
5354
}

packages/sdk/browser/src/goals/GoalTracker.ts

+14-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import escapeStringRegexp from 'escape-string-regexp';
22

3+
import {
4+
addDocumentEventListener,
5+
getHref,
6+
getLocationHash,
7+
getLocationSearch,
8+
querySelectorAll,
9+
} from '../BrowserApi';
310
import { ClickGoal, Goal, Matcher } from './Goals';
411

512
type EventHandler = (goal: Goal) => void;
@@ -37,11 +44,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) {
3744
clickGoals.forEach((goal) => {
3845
let target: Node | null = event.target as Node;
3946
const { selector } = goal;
40-
const elements = document.querySelectorAll(selector);
47+
const elements = querySelectorAll(selector);
4148

4249
// Traverse from the target of the event up the page hierarchy.
4350
// If there are no element that match the selector, then no need to check anything.
44-
while (target && elements.length) {
51+
while (target && elements?.length) {
4552
// The elements are a NodeList, so it doesn't have the array functions. For performance we
4653
// do not convert it to an array.
4754
for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) {
@@ -64,11 +71,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) {
6471
* Tracks the goals on an individual "page" (combination of route, query params, and hash).
6572
*/
6673
export default class GoalTracker {
67-
private clickHandler?: (event: Event) => void;
74+
private cleanup?: () => void;
6875
constructor(goals: Goal[], onEvent: EventHandler) {
6976
const goalsMatchingUrl = goals.filter((goal) =>
7077
goal.urls?.some((matcher) =>
71-
matchesUrl(matcher, window.location.href, window.location.search, window.location.hash),
78+
matchesUrl(matcher, getHref(), getLocationSearch(), getLocationHash()),
7279
),
7380
);
7481

@@ -80,21 +87,19 @@ export default class GoalTracker {
8087
if (clickGoals.length) {
8188
// Click handler is not a member function in order to avoid having to bind it for the event
8289
// handler and then track a reference to that bound handler.
83-
this.clickHandler = (event: Event) => {
90+
const clickHandler = (event: Event) => {
8491
findGoalsForClick(event, clickGoals).forEach((clickGoal) => {
8592
onEvent(clickGoal);
8693
});
8794
};
88-
document.addEventListener('click', this.clickHandler);
95+
this.cleanup = addDocumentEventListener('click', clickHandler);
8996
}
9097
}
9198

9299
/**
93100
* Close the tracker which stops listening to any events.
94101
*/
95102
close() {
96-
if (this.clickHandler) {
97-
document.removeEventListener('click', this.clickHandler);
98-
}
103+
this.cleanup?.();
99104
}
100105
}

packages/sdk/browser/src/goals/LocationWatcher.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { addWindowEventListener, getHref } from '../BrowserApi';
2+
13
export const LOCATION_WATCHER_INTERVAL_MS = 300;
24

35
// Using any for the timer handle because the type is not the same for all
@@ -24,9 +26,9 @@ export class DefaultLocationWatcher {
2426
* @param callback Callback that is executed whenever a URL change is detected.
2527
*/
2628
constructor(callback: () => void) {
27-
this.previousLocation = window.location.href;
29+
this.previousLocation = getHref();
2830
const checkUrl = () => {
29-
const currentLocation = window.location.href;
31+
const currentLocation = getHref();
3032

3133
if (currentLocation !== this.previousLocation) {
3234
this.previousLocation = currentLocation;
@@ -41,10 +43,10 @@ export class DefaultLocationWatcher {
4143
*/
4244
this.watcherHandle = setInterval(checkUrl, LOCATION_WATCHER_INTERVAL_MS);
4345

44-
window.addEventListener('popstate', checkUrl);
46+
const removeListener = addWindowEventListener('popstate', checkUrl);
4547

4648
this.cleanupListeners = () => {
47-
window.removeEventListener('popstate', checkUrl);
49+
removeListener();
4850
};
4951
}
5052

packages/sdk/browser/src/options.ts

+21
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
TypeValidators,
77
} from '@launchdarkly/js-client-sdk-common';
88

9+
const DEFAULT_FLUSH_INTERVAL_SECONDS = 2;
10+
911
/**
1012
* Initialization options for the LaunchDarkly browser SDK.
1113
*/
@@ -35,12 +37,25 @@ export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMo
3537
* This is equivalent to calling `client.setStreaming()` with the same value.
3638
*/
3739
streaming?: boolean;
40+
41+
/**
42+
* Determines if the SDK responds to entering different visibility states to handle tasks such as
43+
* flushing events.
44+
*
45+
* This is true by default. Generally speaking the SDK will be able to most reliably delivery
46+
* events with this setting on.
47+
*
48+
* It may be useful to disable for environments where not all window/document objects are
49+
* available, such as when running the SDK in a browser extension.
50+
*/
51+
automaticBackgroundHandling?: boolean;
3852
}
3953

4054
export interface ValidatedOptions {
4155
fetchGoals: boolean;
4256
eventUrlTransformer: (url: string) => string;
4357
streaming?: boolean;
58+
automaticBackgroundHandling?: boolean;
4459
}
4560

4661
const optDefaults = {
@@ -66,8 +81,14 @@ export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase {
6681
return baseOptions;
6782
}
6883

84+
function applyBrowserDefaults(opts: BrowserOptions) {
85+
// eslint-disable-next-line no-param-reassign
86+
opts.flushInterval ??= DEFAULT_FLUSH_INTERVAL_SECONDS;
87+
}
88+
6989
export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions {
7090
const output: ValidatedOptions = { ...optDefaults };
91+
applyBrowserDefaults(output);
7192

7293
Object.entries(validators).forEach((entry) => {
7394
const [key, validator] = entry as [keyof BrowserOptions, TypeValidator];

packages/sdk/browser/src/platform/BrowserCrypto.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Crypto } from '@launchdarkly/js-client-sdk-common';
22

3+
import { getCrypto } from '../BrowserApi';
34
import BrowserHasher from './BrowserHasher';
45
import randomUuidV4 from './randomUuidV4';
56

67
export default class BrowserCrypto implements Crypto {
78
createHash(algorithm: string): BrowserHasher {
8-
return new BrowserHasher(window.crypto, algorithm);
9+
return new BrowserHasher(getCrypto(), algorithm);
910
}
1011

1112
randomUUID(): string {

0 commit comments

Comments
 (0)