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",