diff --git a/with-react-native/README.md b/with-react-native/README.md index bfae341..d6c7904 100644 --- a/with-react-native/README.md +++ b/with-react-native/README.md @@ -195,10 +195,8 @@ function MyScreen() { const formo = useFormo(); useEffect(() => { - formo.screen("MyScreen", { - category: "main", - source: "navigation", - }); + // screen(name, category?, properties?) + formo.screen("MyScreen", "main", { source: "navigation" }); }, [formo]); return ...; diff --git a/with-react-native/__tests__/formo.test.ts b/with-react-native/__tests__/formo.test.ts index 4aade88..61225e6 100644 --- a/with-react-native/__tests__/formo.test.ts +++ b/with-react-native/__tests__/formo.test.ts @@ -22,11 +22,25 @@ describe("Formo Configuration", () => { expect(typeof options).toBe("object"); }); - it("should include app configuration", () => { + it("should take app identity from app.json, not a hardcoded copy", () => { + // These reach Formo as `app_version` and as the mobile `origin`. A + // hardcoded value that drifts from app.json reports the wrong version on + // every event indefinitely, so assert they are the SAME value rather than + // pinning a literal here (which is how they drifted in the first place). + const appJson = require("../app.json").expo; const options = createFormoOptions(mockWagmiConfig, mockQueryClient); + expect(options.app).toBeDefined(); - expect(options.app!.name).toBe("Formo Analytics Demo"); - expect(options.app!.version).toBe("1.1.0"); + expect(options.app!.name).toBe(appJson.name); + expect(options.app!.version).toBe(appJson.version); + expect(options.app!.bundleId).toBe(appJson.ios.bundleIdentifier); + }); + + it("keeps the iOS bundle id and Android package in sync", () => { + // The SDK sends one bundleId for both platforms, so a mismatch would make + // the same app report two different origins depending on the platform. + const appJson = require("../app.json").expo; + expect(appJson.ios.bundleIdentifier).toBe(appJson.android.package); }); it("should include wagmi configuration", () => { diff --git a/with-react-native/config/formo.ts b/with-react-native/config/formo.ts index 8ec1328..3260e5a 100644 --- a/with-react-native/config/formo.ts +++ b/with-react-native/config/formo.ts @@ -1,4 +1,5 @@ import type { Options } from "@formo/analytics-react-native"; +import Constants from "expo-constants"; import type { Config } from "wagmi"; import type { QueryClient } from "@tanstack/react-query"; @@ -6,13 +7,27 @@ import type { QueryClient } from "@tanstack/react-query"; export const FORMO_WRITE_KEY = process.env.EXPO_PUBLIC_FORMO_WRITE_KEY || "YOUR_FORMO_WRITE_KEY"; +// Read app identity from the Expo config (app.json) rather than hardcoding it, +// so there is one source of truth. These reach Formo as `app_version` and as +// the mobile `origin`, which means a hardcoded value that drifts from app.json +// silently reports the wrong version on every event forever. +const expoConfig = Constants.expoConfig; + // Base Formo Analytics configuration (without wagmi) export const baseFormoOptions: Omit = { - // App information for context enrichment + // App information for context enrichment. + // + // Worth setting explicitly even though the SDK can auto-detect: in Expo Go + // the native modules report EXPO GO's identity (its bundle id and version), + // not your app's, and on React Native Web nothing resolves a bundle id at + // all. Configuring these keeps dev and web builds reporting the real app. app: { - name: "Formo Analytics Demo", - version: "1.1.0", - bundleId: "com.formo.analytics.demo", + name: expoConfig?.name ?? "Formo Analytics Demo", + version: expoConfig?.version ?? "0.0.0", + bundleId: + expoConfig?.ios?.bundleIdentifier ?? + expoConfig?.android?.package ?? + "com.formo.analytics.demo", }, // Event batching configuration diff --git a/with-react-native/jest.setup.js b/with-react-native/jest.setup.js index 7769e2e..1790c4b 100644 --- a/with-react-native/jest.setup.js +++ b/with-react-native/jest.setup.js @@ -98,6 +98,13 @@ jest.mock("@formo/analytics-react-native", () => ({ }), })); +// Mock expo-constants with the real app.json, so config/formo.ts is exercised +// through its actual source of truth rather than its fallbacks. +jest.mock("expo-constants", () => ({ + __esModule: true, + default: { expoConfig: require("./app.json").expo }, +})); + // Mock react-native-safe-area-context jest.mock("react-native-safe-area-context", () => ({ SafeAreaProvider: ({ children }) => children, diff --git a/with-react-native/metro.config.js b/with-react-native/metro.config.js index 42a6697..c92522f 100644 --- a/with-react-native/metro.config.js +++ b/with-react-native/metro.config.js @@ -1,4 +1,5 @@ const { getDefaultConfig } = require("expo/metro-config"); +const fs = require("fs"); const path = require("path"); const config = getDefaultConfig(__dirname); @@ -9,6 +10,22 @@ const sdkPath = path.resolve(__dirname, "../../sdk-react-native"); const projectRoot = __dirname; const projectNodeModules = path.resolve(projectRoot, "node_modules"); +// Is the SDK a local link rather than an npm install? A `pnpm add link:` (or +// `npm link`) leaves a symlink in node_modules; an npm install leaves a real +// directory. Only in the link case should Metro resolve the package to the +// sibling checkout's TypeScript source. +const isSdkLinked = (() => { + try { + const installed = path.resolve(projectNodeModules, SDK_PACKAGE_NAME); + return ( + fs.lstatSync(installed).isSymbolicLink() && + fs.existsSync(path.resolve(sdkPath, "src/index.ts")) + ); + } catch { + return false; // not installed at all, or no sibling checkout + } +})(); + // Watch the SDK directory for changes // Note: sdk-react-native/.watchmanconfig excludes node_modules and lib config.watchFolders = [sdkPath]; @@ -44,7 +61,13 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { // redirect below keys on originModulePath being inside sdkPath, which only // happens when the SDK is consumed as source. It means edits to the SDK hot // reload with no rebuild — the point of linking it in the first place. - if (moduleName === SDK_PACKAGE_NAME) { + // ONLY when the package is actually linked. Redirecting unconditionally + // would hijack an npm-installed SDK for anyone who happens to have a sibling + // sdk-react-native checkout — silently running unreleased local code instead + // of the pinned version — and would resolve to a non-existent path for anyone + // who does not. isSdkLinked is computed once at config load; swapping between + // a link and an npm install already requires a Metro restart. + if (moduleName === SDK_PACKAGE_NAME && isSdkLinked) { return { filePath: path.resolve(sdkPath, "src/index.ts"), type: "sourceFile",