Files
zeekayandHanzo Dev 7f1e0da710 feat(telemetry): emit on React Native — canEmit gate + useScreenTracking (8.1.2)
Native was gated silent alongside SSR (`permitted = hasDom() && …`), so a mobile
app mounting <TelemetryProvider> built a disabled client and sent nothing. React
Native is not SSR: it has `fetch`. Gate emit on `canEmit()` (a DOM *or* React
Native) instead of a DOM, so the event/identify/group/error streams send on
mobile while the DOM-only planes (History pageviews, session replay) stay off.

- env: `isReactNative()` (navigator.product) + an unconfigured RN app reports
  as `mobile`, mirroring Tauri→desktop.
- telemetry: `canEmit()` = hasDom() || isReactNative(); the gate reads it.
- useRouteTracking: gate on `hasDom()` not `typeof window` — RN has a window but
  no History, and reaching for window.location/history there threw.
- TelemetryProvider: guard the cross-tab `storage` listener behind hasDom() (RN
  has no window.addEventListener) while keeping in-app consent live everywhere.
- useScreenTracking(navigationRef): the RN counterpart to History pageviews,
  duck-typed against React Navigation so the package takes no dependency on it.
- @hanzo/event floor → ^0.3.16 (RN-safe init) + a native.test.ts proving emit.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 15:58:15 -07:00

77 lines
2.8 KiB
TypeScript

// @vitest-environment node
//
// React Native emits. RN is a JS runtime with a global `window` but no DOM
// (`navigator.product === 'ReactNative'`), and through 8.1.1 it was gated silent
// alongside SSR. It is NOT SSR: it has `fetch`, so the event / identify / group /
// error streams all send — only the DOM-only planes (History pageviews, session
// replay) stay off. This proves emit against a minimal RN stub in a Node env,
// where the only globals are the ones a mobile runtime actually provides.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canEmit, createTelemetry, hasDom } from '../src/telemetry'
import { isReactNative } from '../src/env'
interface Sent {
url: string
batch: Array<Record<string, unknown>>
}
let sent: Sent[]
beforeEach(() => {
sent = []
// A React Native runtime: a `window` with no `document`/`location`, a
// `navigator` naming the platform, and `fetch` — nothing a browser has that
// RN does not, and nothing it lacks. `vi.stubGlobal` is required because Node
// 21+ defines `navigator` as a getter-only global that a bare assignment
// cannot override.
vi.stubGlobal('window', {})
vi.stubGlobal('navigator', { product: 'ReactNative' })
vi.stubGlobal('fetch', (url: string, init?: { body?: string }): Promise<unknown> => {
const body = init?.body ? (JSON.parse(init.body) as { batch?: unknown[] }) : {}
const batch = (body.batch ?? []) as Array<Record<string, unknown>>
sent.push({ url: String(url), batch })
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ accepted: batch.length, dropped: 0 }),
})
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('react native', () => {
it('is a live host without a DOM', () => {
expect(hasDom()).toBe(false)
expect(isReactNative()).toBe(true)
expect(canEmit()).toBe(true)
})
it('builds an ENABLED client — native is not SSR', () => {
expect(createTelemetry().enabled).toBe(true)
})
it('names an unconfigured mobile app `mobile` from the runtime', () => {
expect(createTelemetry().product).toBe('mobile')
})
it('emits through fetch: init never throws, and a pageview reaches the one front door', async () => {
const t = createTelemetry()
// The @hanzo/event fix (>=0.3.16): init() reads as non-browser on RN and
// returns before touching window.location, rather than throwing.
expect(() => t.client.init()).not.toThrow()
t.track('screen_opened', { screen: 'Home' })
t.pageview('Home')
t.flush()
await new Promise((r) => setTimeout(r, 10)) // let the transport drain
expect(sent.length).toBeGreaterThan(0)
expect(sent[0].url).toBe('https://api.hanzo.ai/v1/event')
const types = sent.flatMap((s) => s.batch.map((e) => e.type))
expect(types).toContain('pageview')
})
})