Skip to content

Commit 1cc0bbf

Browse files
authored
fix: support React Native (window without document) (#223)
1 parent 4aa3d96 commit 1cc0bbf

4 files changed

Lines changed: 81 additions & 4 deletions

File tree

src/modules/analytics.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "./analytics.types";
1111
import { getSharedInstance } from "../utils/sharedInstance.js";
1212
import type { AuthModule } from "./auth.types";
13-
import { generateUuid } from "../utils/common.js";
13+
import { generateUuid, isReactNative } from "../utils/common.js";
1414

1515
export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
1616
export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
@@ -70,7 +70,11 @@ export const createAnalyticsModule = ({
7070
// prevent overflow of events //
7171
const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;
7272

73-
if (!analyticsSharedState.config?.enabled) {
73+
// Disable analytics on React Native. It defines `window` but not `document`,
74+
// so the per-callsite `typeof window` guards below aren't enough to keep it
75+
// from touching `document` (e.g. `document.referrer` on init). Node/SSR is
76+
// still handled by those `window` guards, so this doesn't affect it.
77+
if (!analyticsSharedState.config?.enabled || isReactNative) {
7478
return {
7579
track: () => {},
7680
cleanup: () => {},
@@ -328,7 +332,10 @@ async function getSessionContext(
328332
export function getAnalyticsConfigFromUrlParams():
329333
| AnalyticsModuleOptions
330334
| undefined {
331-
if (typeof window === "undefined") return undefined;
335+
// `window.location` is absent on React Native. This runs at module load (via
336+
// the shared-state factory), so an unguarded `window.location.search` would
337+
// throw on import there.
338+
if (typeof window === "undefined" || !window.location) return undefined;
332339
const urlParams = new URLSearchParams(window.location.search);
333340
const analyticsEnable = urlParams.get(ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY);
334341

src/utils/axios-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ export function createAxiosClient({
175175

176176
// Add origin URL in browser environment
177177
client.interceptors.request.use((config) => {
178-
if (typeof window !== "undefined") {
178+
// `window.location` is absent on React Native (where `window` still exists),
179+
// so guard on it before reading `.href`.
180+
if (typeof window !== "undefined" && window.location) {
179181
config.headers.set("X-Origin-URL", window.location.href);
180182
// On unauthenticated requests, attach a stable anonymous visitor id so the
181183
// backend can support anonymous agent access (conversation grouping + ownership).

src/utils/common.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export const isNode = typeof window === "undefined";
22
export const isInIFrame = !isNode && window.self !== window.top;
33

4+
// React Native defines `window` (so `isNode` is false there) but not `document`.
5+
// Browser-only code paths gated on `window`/`isNode` alone would run — and crash —
6+
// on React Native. Node (no `window`) is already handled by those `window` guards;
7+
// this flags the window-without-a-DOM case that isn't.
8+
export const isReactNative = !isNode && typeof document === "undefined";
9+
410
export const generateUuid = () => {
511
return (
612
Math.random().toString(36).substring(2, 15) +

tests/unit/react-native.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
3+
// React Native defines a limited `window` polyfill but has no `document`,
4+
// `localStorage`, or `window.location`. The SDK must import, construct, and
5+
// make requests there without touching those globals. Analytics is disabled.
6+
describe("React Native environment", () => {
7+
afterEach(() => {
8+
vi.unstubAllGlobals();
9+
vi.resetModules();
10+
});
11+
12+
function stubReactNativeGlobals() {
13+
// `window` exists but is a bare object: no `location`, no `addEventListener`,
14+
// no `localStorage`.
15+
vi.stubGlobal("window", {});
16+
// `document` is not defined on React Native.
17+
vi.stubGlobal("document", undefined);
18+
vi.stubGlobal("localStorage", undefined);
19+
}
20+
21+
test("importing and constructing the client does not throw", async () => {
22+
stubReactNativeGlobals();
23+
// Re-import so module-load code (e.g. the analytics shared-state factory)
24+
// is evaluated against the React Native globals.
25+
vi.resetModules();
26+
const { createClient } = await import("../../src/index.ts");
27+
28+
const client = createClient({
29+
serverUrl: "https://api.base44.com",
30+
appId: "test-app-id",
31+
});
32+
33+
expect(client.analytics).toBeDefined();
34+
expect(() => client.cleanup()).not.toThrow();
35+
});
36+
37+
test("analytics.track is a safe noop (no document access)", async () => {
38+
stubReactNativeGlobals();
39+
vi.resetModules();
40+
const { createClient } = await import("../../src/index.ts");
41+
42+
const client = createClient({
43+
serverUrl: "https://api.base44.com",
44+
appId: "test-app-id",
45+
});
46+
47+
expect(() =>
48+
client.analytics.track({ eventName: "test-event" })
49+
).not.toThrow();
50+
51+
client.cleanup();
52+
});
53+
54+
test("isReactNative reflects the window-without-document environment", async () => {
55+
stubReactNativeGlobals();
56+
vi.resetModules();
57+
const { isReactNative, isNode } = await import("../../src/utils/common.ts");
58+
59+
expect(isNode).toBe(false);
60+
expect(isReactNative).toBe(true);
61+
});
62+
});

0 commit comments

Comments
 (0)