Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/modules/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__";
Expand Down Expand Up @@ -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: () => {},
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion src/utils/axios-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions src/utils/common.ts
Original file line number Diff line number Diff line change
@@ -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) +
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/react-native.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading