diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 3ad8b64..ef1238c 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -10,7 +10,7 @@ import { } from "./analytics.types"; import { getSharedInstance } from "../utils/sharedInstance.js"; import type { AuthModule } from "./auth.types"; -import { generateUuid } from "../utils/common.js"; +import { generateUuid, isReactNative } from "../utils/common.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -70,7 +70,11 @@ export const createAnalyticsModule = ({ // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; - if (!analyticsSharedState.config?.enabled) { + // Disable analytics on React Native. It defines `window` but not `document`, + // so the per-callsite `typeof window` guards below aren't enough to keep it + // from touching `document` (e.g. `document.referrer` on init). Node/SSR is + // still handled by those `window` guards, so this doesn't affect it. + if (!analyticsSharedState.config?.enabled || isReactNative) { return { track: () => {}, cleanup: () => {}, @@ -328,7 +332,10 @@ async function getSessionContext( export function getAnalyticsConfigFromUrlParams(): | AnalyticsModuleOptions | undefined { - if (typeof window === "undefined") return undefined; + // `window.location` is absent on React Native. This runs at module load (via + // the shared-state factory), so an unguarded `window.location.search` would + // throw on import there. + if (typeof window === "undefined" || !window.location) return undefined; const urlParams = new URLSearchParams(window.location.search); const analyticsEnable = urlParams.get(ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY); diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 3c9e9d1..f432e9d 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -175,7 +175,9 @@ export function createAxiosClient({ // Add origin URL in browser environment client.interceptors.request.use((config) => { - if (typeof window !== "undefined") { + // `window.location` is absent on React Native (where `window` still exists), + // so guard on it before reading `.href`. + if (typeof window !== "undefined" && window.location) { config.headers.set("X-Origin-URL", window.location.href); // On unauthenticated requests, attach a stable anonymous visitor id so the // backend can support anonymous agent access (conversation grouping + ownership). diff --git a/src/utils/common.ts b/src/utils/common.ts index 61d8d17..d7bec21 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,12 @@ export const isNode = typeof window === "undefined"; export const isInIFrame = !isNode && window.self !== window.top; +// React Native defines `window` (so `isNode` is false there) but not `document`. +// Browser-only code paths gated on `window`/`isNode` alone would run — and crash — +// on React Native. Node (no `window`) is already handled by those `window` guards; +// this flags the window-without-a-DOM case that isn't. +export const isReactNative = !isNode && typeof document === "undefined"; + export const generateUuid = () => { return ( Math.random().toString(36).substring(2, 15) + diff --git a/tests/unit/react-native.test.ts b/tests/unit/react-native.test.ts new file mode 100644 index 0000000..1c8017d --- /dev/null +++ b/tests/unit/react-native.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +// React Native defines a limited `window` polyfill but has no `document`, +// `localStorage`, or `window.location`. The SDK must import, construct, and +// make requests there without touching those globals. Analytics is disabled. +describe("React Native environment", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + function stubReactNativeGlobals() { + // `window` exists but is a bare object: no `location`, no `addEventListener`, + // no `localStorage`. + vi.stubGlobal("window", {}); + // `document` is not defined on React Native. + vi.stubGlobal("document", undefined); + vi.stubGlobal("localStorage", undefined); + } + + test("importing and constructing the client does not throw", async () => { + stubReactNativeGlobals(); + // Re-import so module-load code (e.g. the analytics shared-state factory) + // is evaluated against the React Native globals. + vi.resetModules(); + const { createClient } = await import("../../src/index.ts"); + + const client = createClient({ + serverUrl: "https://api.base44.com", + appId: "test-app-id", + }); + + expect(client.analytics).toBeDefined(); + expect(() => client.cleanup()).not.toThrow(); + }); + + test("analytics.track is a safe noop (no document access)", async () => { + stubReactNativeGlobals(); + vi.resetModules(); + const { createClient } = await import("../../src/index.ts"); + + const client = createClient({ + serverUrl: "https://api.base44.com", + appId: "test-app-id", + }); + + expect(() => + client.analytics.track({ eventName: "test-event" }) + ).not.toThrow(); + + client.cleanup(); + }); + + test("isReactNative reflects the window-without-document environment", async () => { + stubReactNativeGlobals(); + vi.resetModules(); + const { isReactNative, isNode } = await import("../../src/utils/common.ts"); + + expect(isNode).toBe(false); + expect(isReactNative).toBe(true); + }); +});