Skip to content

Commit 0c99a24

Browse files
authored
Add lint rule to avoid direct usage of browser globals (#735)
## Motivation / Description Our JS SDK can be used in non-browser environments, for example, in Expo Go. We don't have many tests to make sure we don't break things in one of these environments. This PR adds a linter rule that will error when using browser globals like `window` or `document` directly. Instead, it adds some helpers `getWindow`, `getNullableWindow`, `getDocument` and `getNullableDocument` to access these in a more intended mechanism. Note that all purchase flows do require a browser environment right now, so we are using the `getWindow` and `getDocument` method in most of those flows. Credits for the idea to @nihalgonsalves 🫶 ## Changes introduced ## Linear ticket (if any) ## Additional comments
1 parent 6f897ab commit 0c99a24

File tree

8 files changed

+246
-32
lines changed

8 files changed

+246
-32
lines changed

eslint.config.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,30 @@ export default [
1616
prefer: "type-imports",
1717
},
1818
],
19+
"no-restricted-globals": [
20+
"error",
21+
{
22+
name: "window",
23+
message:
24+
"Use getWindow() or getNullableWindow() from helpers/browser-globals instead.",
25+
},
26+
{
27+
name: "document",
28+
message:
29+
"Use getDocument() or getNullableDocument() from helpers/browser-globals instead.",
30+
},
31+
],
32+
},
33+
},
34+
{
35+
files: [
36+
"**/*.test.{js,ts}",
37+
"**/tests/**/*.{js,ts}",
38+
"**/vitest.setup.js",
39+
"examples/webbilling-demo/**/*.{js,ts,jsx,tsx}",
40+
],
41+
rules: {
42+
"no-restricted-globals": "off",
1943
},
2044
},
2145
];

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"build:dev": "tsc && vite build --mode development",
4242
"build:dev-watch": "tsc && vite build --mode development --watch",
4343
"preview": "vite preview",
44-
"lint": "prettier --check .",
44+
"lint": "prettier --check . && eslint src/",
4545
"format": "prettier --write .",
4646
"pack-build": "npm run build && npm pack",
4747
"test": "vitest run",

src/behavioural-events/sdk-event-context.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { VERSION } from "../helpers/constants";
22
import { type EventContext } from "./event";
3+
import {
4+
getNullableDocument,
5+
getNullableWindow,
6+
} from "../helpers/browser-globals";
37

48
export type SDKEventContextSource = "sdk" | "wpl" | string;
59

@@ -28,9 +32,9 @@ export function buildEventContext(
2832
rcSource: string | null,
2933
): SDKEventContext & EventContext {
3034
// Guard against environments where window.location is not available
31-
const hasWindowLocation = typeof window !== "undefined" && window.location;
32-
const urlParams = hasWindowLocation
33-
? new URLSearchParams(window.location.search)
35+
const win = getNullableWindow();
36+
const urlParams = win?.location
37+
? new URLSearchParams(win.location.search)
3438
: new URLSearchParams();
3539

3640
let screenWidth: number | null = null;
@@ -39,12 +43,10 @@ export function buildEventContext(
3943
screenWidth = screen.width;
4044
screenHeight = screen.height;
4145
}
42-
let pageReferrer: string | null = null;
43-
let pageTitle: string | null = null;
44-
if (typeof document !== "undefined" && document) {
45-
pageReferrer = document.referrer;
46-
pageTitle = document.title;
47-
}
46+
47+
const doc = getNullableDocument();
48+
const pageReferrer = doc?.referrer ?? null;
49+
const pageTitle = doc?.title ?? null;
4850

4951
return {
5052
libraryName: "purchases-js",
@@ -60,8 +62,8 @@ export function buildEventContext(
6062
utmContent: urlParams.get("utm_content") ?? null,
6163
utmTerm: urlParams.get("utm_term") ?? null,
6264
pageReferrer: pageReferrer,
63-
pageUrl: hasWindowLocation
64-
? `${window.location.origin}${window.location.pathname}`
65+
pageUrl: win?.location
66+
? `${win.location.origin}${win.location.pathname}`
6567
: "",
6668
pageTitle: pageTitle,
6769
source: source,

src/helpers/browser-globals.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* eslint-disable no-restricted-globals */
2+
3+
/**
4+
* Helper functions to safely access browser globals (window, document)
5+
* in environments that may not have them available (e.g., Node.js, Expo Go).
6+
*/
7+
8+
import { ErrorCode, PurchasesError } from "../entities/errors";
9+
10+
/**
11+
* Access the `window` global or fail.
12+
* @returns The `window` object if defined
13+
* @throws PurchasesError if `window` is not available
14+
*/
15+
export function getWindow(): Window {
16+
if (typeof window !== "undefined") {
17+
return window;
18+
}
19+
20+
throw new PurchasesError(
21+
ErrorCode.UnsupportedError,
22+
"window is not available. This SDK requires a browser environment for this operation.",
23+
);
24+
}
25+
26+
/**
27+
* Safely access the `window` global, allowing undefined.
28+
* @returns The `window` object or undefined if not available
29+
*/
30+
export function getNullableWindow(): Window | undefined {
31+
if (typeof window !== "undefined") {
32+
return window;
33+
}
34+
return undefined;
35+
}
36+
37+
/**
38+
* Access the `document` global or fail.
39+
* @returns The `document` object if defined
40+
* @throws PurchasesError if `document` is not available
41+
*/
42+
export function getDocument(): Document {
43+
if (typeof document !== "undefined") {
44+
return document;
45+
}
46+
47+
throw new PurchasesError(
48+
ErrorCode.UnsupportedError,
49+
"document is not available. This SDK requires a browser environment for this operation.",
50+
);
51+
}
52+
53+
/**
54+
* Safely access the `document` global, allowing undefined.
55+
* @returns The `document` object or undefined if not available
56+
*/
57+
export function getNullableDocument(): Document | undefined {
58+
if (typeof document !== "undefined") {
59+
return document;
60+
}
61+
return undefined;
62+
}

src/helpers/simulated-store-purchase-helper.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import SimulatedStoreModal from "../ui/molecules/simulated-store-modal.svelte";
66
import type { Backend } from "../networking/backend";
77
import { postSimulatedStoreReceipt } from "./simulated-store-post-receipt-helper";
88
import { Logger } from "./logger";
9+
import { getDocument } from "./browser-globals";
910

1011
export function purchaseSimulatedStoreProduct(
1112
purchaseParams: PurchaseParams,
@@ -27,14 +28,15 @@ export function purchaseSimulatedStoreProduct(
2728

2829
return new Promise((resolve, reject) => {
2930
// Create a container for the modal
30-
const container = document.createElement("div");
31-
document.body.appendChild(container);
31+
const doc = getDocument();
32+
const container = doc.createElement("div");
33+
doc.body.appendChild(container);
3234

3335
const cleanup = () => {
3436
if (component) {
3537
unmount(component);
3638
}
37-
document.body.removeChild(container);
39+
doc.body.removeChild(container);
3840
};
3941

4042
let component: ReturnType<typeof mount> | null = null;

src/helpers/utm-params.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { getNullableWindow } from "./browser-globals";
2+
13
export const autoParseUTMParams = () => {
24
// Guard against environments where window.location is not available
3-
const hasWindowLocation = typeof window !== "undefined" && window.location;
4-
const params = hasWindowLocation
5-
? new URLSearchParams(window.location.search)
5+
const win = getNullableWindow();
6+
const params = win?.location
7+
? new URLSearchParams(win.location.search)
68
: new URLSearchParams();
79

810
const possibleParams = [

src/main.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import type {
104104
PresentExpressPurchaseButtonParams,
105105
} from "./entities/present-express-purchase-button-params";
106106
import { ExpressPurchaseButtonWrapper } from "./ui/express-purchase-button/express-purchase-button-wrapper.svelte";
107+
import { getWindow, getDocument } from "./helpers/browser-globals";
107108

108109
export { ProductType } from "./entities/offerings";
109110
export type {
@@ -464,12 +465,12 @@ export class Purchases {
464465
const htmlTarget = paywallParams.htmlTarget;
465466
let wasRootAutoCreated = false;
466467

467-
let resolvedHTMLTarget =
468-
htmlTarget ?? document.getElementById("rcb-ui-pw-root");
468+
const doc = getDocument();
469+
let resolvedHTMLTarget = htmlTarget ?? doc.getElementById("rcb-ui-pw-root");
469470

470471
if (resolvedHTMLTarget === null) {
471472
wasRootAutoCreated = true;
472-
const element = document.createElement("div");
473+
const element = doc.createElement("div");
473474
element.id = "rcb-ui-pw-root";
474475
element.className = "rcb-ui-pw-root";
475476
// one point less than the purchase flow modal.
@@ -481,14 +482,14 @@ export class Purchases {
481482
element.style.height = "100%";
482483
element.style.overflow = "auto";
483484
element.style.backgroundColor = "rgba(0, 0, 0, 0.4)";
484-
if (document.body.offsetWidth > 968) {
485+
if (doc.body.offsetWidth > 968) {
485486
element.style.display = "flex";
486487
element.style.justifyContent = "center";
487488
element.style.alignItems = "center";
488489
}
489490
element.style.transition = "opacity 0.3s";
490491
element.style.opacity = "0";
491-
document.body.appendChild(element);
492+
doc.body.appendChild(element);
492493
resolvedHTMLTarget = element;
493494
}
494495

@@ -598,7 +599,8 @@ export class Purchases {
598599

599600
// Opinionated approach:
600601
// navigating to the URL in a new tab.
601-
window.open(url, "_blank")?.focus();
602+
const win = getWindow();
603+
win.open(url, "_blank")?.focus();
602604
};
603605

604606
const onRestorePurchasesClicked = () => {
@@ -1036,8 +1038,9 @@ export class Purchases {
10361038
const isInElement = htmlTarget !== undefined;
10371039

10381040
return new Promise((resolve, reject) => {
1041+
const win = getWindow();
10391042
if (!isInElement) {
1040-
window.history.pushState({ checkoutOpen: true }, "");
1043+
win.history.pushState({ checkoutOpen: true }, "");
10411044
}
10421045

10431046
const unmountPurchaseUi = () => {
@@ -1053,7 +1056,7 @@ export class Purchases {
10531056
);
10541057

10551058
if (!isInElement && onClose) {
1056-
window.addEventListener("popstate", onClose as EventListener);
1059+
win.addEventListener("popstate", onClose as EventListener);
10571060
}
10581061

10591062
const onFinished = this.createCheckoutOnFinishedHandler(
@@ -1147,8 +1150,9 @@ export class Purchases {
11471150
const isInElement = htmlTarget !== undefined;
11481151

11491152
return new Promise((resolve, reject) => {
1153+
const win = getWindow();
11501154
if (!isInElement) {
1151-
window.history.pushState({ checkoutOpen: true }, "");
1155+
win.history.pushState({ checkoutOpen: true }, "");
11521156
}
11531157

11541158
const unmountPaddlePurchaseUi = () => {
@@ -1209,13 +1213,13 @@ export class Purchases {
12091213
* If no element is found, creates a new div with className "rcb-ui-root".
12101214
*/
12111215
private resolveHTMLTarget(htmlTarget?: HTMLElement): HTMLElement {
1212-
let resolvedHTMLTarget =
1213-
htmlTarget ?? document.getElementById("rcb-ui-root");
1216+
const doc = getDocument();
1217+
let resolvedHTMLTarget = htmlTarget ?? doc.getElementById("rcb-ui-root");
12141218

12151219
if (resolvedHTMLTarget === null) {
1216-
const element = document.createElement("div");
1220+
const element = doc.createElement("div");
12171221
element.className = "rcb-ui-root";
1218-
document.body.appendChild(element);
1222+
doc.body.appendChild(element);
12191223
resolvedHTMLTarget = element;
12201224
}
12211225

@@ -1242,7 +1246,8 @@ export class Purchases {
12421246
const onClose = () => {
12431247
const event = createCheckoutSessionEndClosedEvent();
12441248
this.eventsTracker.trackSDKEvent(event);
1245-
window.removeEventListener("popstate", onClose as EventListener);
1249+
const win = getWindow();
1250+
win.removeEventListener("popstate", onClose as EventListener);
12461251

12471252
callback?.();
12481253

0 commit comments

Comments
 (0)