Skip to content

Commit 270bca4

Browse files
committed
Refactoring
1 parent 564f5b8 commit 270bca4

File tree

9 files changed

+167
-21
lines changed

9 files changed

+167
-21
lines changed

packages/sdk/browser/__tests__/BrowserStateDetector.test.ts

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import {
1717

1818
import BrowserDataManager from './BrowserDataManager';
1919
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
20+
import { registerStateDetection } from './BrowserStateDetector';
2021
import GoalManager from './goals/GoalManager';
2122
import { Goal, isClick } from './goals/Goals';
2223
import validateOptions, { BrowserOptions, filterToBaseOptions } from './options';
2324
import BrowserPlatform from './platform/BrowserPlatform';
24-
import { registerStateDetection } from './BrowserStateDetector';
25+
import { getHref } from './BrowserApi';
2526

2627
/**
2728
*
@@ -162,7 +163,7 @@ export class BrowserClient extends LDClientImpl implements LDClient {
162163
event.data,
163164
event.metricValue,
164165
event.samplingRatio,
165-
eventUrlTransformer(window.location.href),
166+
eventUrlTransformer(getHref()),
166167
),
167168
},
168169
);
@@ -213,7 +214,9 @@ export class BrowserClient extends LDClientImpl implements LDClient {
213214
// would return that promise.
214215
this.goalManager.initialize();
215216

216-
registerStateDetection(() => this.flush());
217+
if (validatedBrowserOptions.automaticBackgroundHandling) {
218+
registerStateDetection(() => this.flush());
219+
}
217220
}
218221
}
219222

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export function registerStateDetection(requestFlush: () => void) {
1+
import { addDocumentEventListener, addWindowEventListener, getVisibility } from './BrowserApi';
2+
3+
export function registerStateDetection(requestFlush: () => void): () => void {
24
// When the visibility of the page changes to hidden we want to flush any pending events.
35
//
46
// This is handled with visibility, instead of beforeunload/unload
@@ -10,11 +12,16 @@ export function registerStateDetection(requestFlush: () => void) {
1012
// This also may provide more opportunity for the events to get flushed.
1113
//
1214
const handleVisibilityChange = () => {
13-
if (document.visibilityState === 'hidden') {
15+
if (getVisibility() === 'hidden') {
1416
requestFlush();
1517
}
1618
};
1719

18-
document.addEventListener('visibilitychange', handleVisibilityChange);
19-
window.addEventListener('pagehide', requestFlush);
20+
const removeDocListener = addDocumentEventListener('visibilitychange', handleVisibilityChange);
21+
const removeWindowListener = addWindowEventListener('pagehide', requestFlush);
22+
23+
return () => {
24+
removeDocListener();
25+
removeWindowListener();
26+
};
2027
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk
33
import { Goal } from './Goals';
44
import GoalTracker from './GoalTracker';
55
import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher';
6+
import { getHref } from '../BrowserApi';
67

78
export default class GoalManager {
89
private goals?: Goal[] = [];
@@ -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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import escapeStringRegexp from 'escape-string-regexp';
22

3+
import { addDocumentEventListener, getHref, getLocationHash, getLocationSearch, querySelectorAll } from '../BrowserApi';
34
import { ClickGoal, Goal, Matcher } from './Goals';
45

56
type EventHandler = (goal: Goal) => void;
@@ -37,11 +38,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) {
3738
clickGoals.forEach((goal) => {
3839
let target: Node | null = event.target as Node;
3940
const { selector } = goal;
40-
const elements = document.querySelectorAll(selector);
41+
const elements = querySelectorAll(selector);
4142

4243
// Traverse from the target of the event up the page hierarchy.
4344
// If there are no element that match the selector, then no need to check anything.
44-
while (target && elements.length) {
45+
while (target && elements?.length) {
4546
// The elements are a NodeList, so it doesn't have the array functions. For performance we
4647
// do not convert it to an array.
4748
for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) {
@@ -64,11 +65,11 @@ function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) {
6465
* Tracks the goals on an individual "page" (combination of route, query params, and hash).
6566
*/
6667
export default class GoalTracker {
67-
private clickHandler?: (event: Event) => void;
68+
private cleanup?: () => void;
6869
constructor(goals: Goal[], onEvent: EventHandler) {
6970
const goalsMatchingUrl = goals.filter((goal) =>
7071
goal.urls?.some((matcher) =>
71-
matchesUrl(matcher, window.location.href, window.location.search, window.location.hash),
72+
matchesUrl(matcher, getHref(), getLocationSearch(), getLocationHash()),
7273
),
7374
);
7475

@@ -80,21 +81,21 @@ export default class GoalTracker {
8081
if (clickGoals.length) {
8182
// Click handler is not a member function in order to avoid having to bind it for the event
8283
// handler and then track a reference to that bound handler.
83-
this.clickHandler = (event: Event) => {
84+
const clickHandler = (event: Event) => {
8485
findGoalsForClick(event, clickGoals).forEach((clickGoal) => {
8586
onEvent(clickGoal);
8687
});
8788
};
88-
document.addEventListener('click', this.clickHandler);
89+
this.cleanup = addDocumentEventListener('click', clickHandler);
8990
}
9091
}
9192

9293
/**
9394
* Close the tracker which stops listening to any events.
9495
*/
9596
close() {
96-
if (this.clickHandler) {
97-
document.removeEventListener('click', this.clickHandler);
97+
if (this.cleanup) {
98+
this.cleanup();
9899
}
99100
}
100101
}

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

Lines changed: 6 additions & 4 deletions
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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,25 @@ export interface BrowserOptions extends Omit<LDOptionsBase, 'initialConnectionMo
3737
* This is equivalent to calling `client.setStreaming()` with the same value.
3838
*/
3939
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;
4052
}
4153

4254
export interface ValidatedOptions {
4355
fetchGoals: boolean;
4456
eventUrlTransformer: (url: string) => string;
4557
streaming?: boolean;
58+
automaticBackgroundHandling?: boolean;
4659
}
4760

4861
const optDefaults = {

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

Lines changed: 2 additions & 1 deletion
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)