From ae816d7a8bec2e4f921c31665a37c9832b618db4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 13:31:11 +0000 Subject: [PATCH 1/2] Persist install state and prepare 0.3.0 release Store installId, tracked flag, attribution id and offer via optional AsyncStorage so trackInstall posts once per install, cold launches restore values, and identify sends the stored attributionId. Add debug logging with redacted API keys, Jest coverage, changelog and package version bump. Co-authored-by: Kenny Ye --- CHANGELOG.md | 6 ++ README.md | 13 ++- __tests__/tapAppLink.test.ts | 155 +++++++++++++++++++++++++++++++---- package-lock.json | 47 ++++++++++- package.json | 10 ++- src/index.ts | 1 + src/storage.ts | 120 +++++++++++++++++++++++++++ src/tapAppLink.ts | 148 +++++++++++++++++++++++++++++---- 8 files changed, 463 insertions(+), 37 deletions(-) create mode 100644 src/storage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdfb53..f4489e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 + +- Persist install id, tracked flag, attribution id and offer across launches (AsyncStorage optional peer). +- Send `installId` on `/ingestInstall` so the server can dedupe; `trackInstall` posts only once per install. +- Add opt-in `debug` configure flag with redacted API key logging. + ## 0.2.0 - Remove `discountBps` from `TapAppLinkOffer`. Present `billingOfferId` on the paywall. diff --git a/README.md b/README.md index 3b0ea3a..a6cb4ff 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,19 @@ Works in Expo **dev clients and production builds**. Expo Go cannot load the And ## Install ```bash -yarn add @tapapplink/react-native +yarn add @tapapplink/react-native @react-native-async-storage/async-storage ``` +`@react-native-async-storage/async-storage` is an optional peer used to persist install state across cold launches on both iOS and Android. Without it, tracking still works for the current process only. + Bare React Native iOS apps should run `cd ios && pod install` after installing. Rebuild the native binary after adding the package. +### Why AsyncStorage (not the Android native module) + +The Play Install Referrer bridge is Android-only. Storing install state only there would leave iOS (and Expo iOS builds) without persistence. AsyncStorage is the usual cross-platform key/value store for Expo and bare React Native, so one path covers both platforms. + ## Usage ```ts @@ -22,6 +28,7 @@ import { TapAppLink } from "@tapapplink/react-native"; TapAppLink.configure({ publicKey: "etk_live_…", environment: __DEV__ ? "sandbox" : "production", + debug: __DEV__, }); await TapAppLink.trackInstall(); @@ -30,6 +37,10 @@ await TapAppLink.setAppUserId(await Purchases.getAppUserID()); const offer = TapAppLink.getOffer(); ``` +`trackInstall` posts `/ingestInstall` once per install (keyed by a persisted `installId`). Later launches restore the stored attribution id and offer without posting again. `setAppUserId` sends that stored `attributionId` after hydrate. + +Set `debug: true` to log each request, response and stored state. The API key is redacted in those logs. + Purchases are attributed with billing **webhooks**, not a client `trackPurchase` call. ## Publishing diff --git a/__tests__/tapAppLink.test.ts b/__tests__/tapAppLink.test.ts index 297c065..407f92b 100644 --- a/__tests__/tapAppLink.test.ts +++ b/__tests__/tapAppLink.test.ts @@ -1,14 +1,31 @@ +const asyncStore = new Map(); + jest.mock("react-native", () => ({ NativeModules: {}, Platform: { OS: "ios", isPad: false }, })); +jest.mock("@react-native-async-storage/async-storage", () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => asyncStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + asyncStore.set(key, value); + }), + removeItem: jest.fn(async (key: string) => { + asyncStore.delete(key); + }), + }, +})); + import { TapAppLink } from "../src/tapAppLink"; +import { STORAGE_KEY } from "../src/storage"; describe("TapAppLink", () => { beforeEach(async () => { + asyncStore.clear(); await TapAppLink.resetForTesting(); - jest.restoreAllMocks(); + jest.clearAllMocks(); }); it("requires configure before trackInstall", async () => { @@ -17,8 +34,9 @@ describe("TapAppLink", () => { ); }); - it("tracks install once and caches offer", async () => { + it("tracks install once, sends installId, and caches offer", async () => { const fetchMock = jest.fn().mockResolvedValue({ + status: 200, json: async () => ({ attributionId: "attr_123", offer: { @@ -31,7 +49,7 @@ describe("TapAppLink", () => { global.fetch = fetchMock as unknown as typeof fetch; TapAppLink.configure({ - publicKey: "etk_test", + publicKey: "etk_test_key", environment: "sandbox", ingestUrl: "https://example.test", }); @@ -40,39 +58,146 @@ describe("TapAppLink", () => { const second = await TapAppLink.trackInstall(); expect(first).toMatchObject({ attributionId: "attr_123" }); - expect(second).toEqual({ matched: false, skipped: true }); + expect(second).toMatchObject({ + matched: false, + skipped: true, + attributionId: "attr_123", + offer: { + creatorName: "Ada", + promoCode: "ADA10", + billingOfferId: "offer_1", + }, + }); expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as { installId: string }; + expect(typeof body.installId).toBe("string"); + expect(body.installId.length).toBeGreaterThan(0); + expect(TapAppLink.getInstallId()).toBe(body.installId); expect(TapAppLink.getOffer()).toEqual({ creatorName: "Ada", promoCode: "ADA10", billingOfferId: "offer_1", }); expect(TapAppLink.getAttributionId()).toBe("attr_123"); + expect(asyncStore.get(STORAGE_KEY)).toContain("attr_123"); }); - it("posts identify with app user id", async () => { + it("does not post ingestInstall again after a cold start with persisted state", async () => { const fetchMock = jest.fn().mockResolvedValue({ - json: async () => ({ ok: true }), + status: 200, + json: async () => ({ + attributionId: "attr_cold", + offer: { + creatorName: "Bea", + promoCode: null, + billingOfferId: "offer_2", + }, + }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); + await TapAppLink.trackInstall(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const installId = TapAppLink.getInstallId(); + + // Simulate process death: clear in-memory SDK state but keep AsyncStorage. + const persisted = asyncStore.get(STORAGE_KEY); + expect(persisted).toBeTruthy(); + await TapAppLink.resetForTesting(); + asyncStore.set(STORAGE_KEY, persisted!); + + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); + const resumed = await TapAppLink.trackInstall(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(resumed).toMatchObject({ + skipped: true, + installId, + attributionId: "attr_cold", + offer: { + creatorName: "Bea", + promoCode: null, + billingOfferId: "offer_2", + }, }); + expect(TapAppLink.getAttributionId()).toBe("attr_cold"); + expect(TapAppLink.getOffer()?.creatorName).toBe("Bea"); + }); + + it("sends stored attributionId on identify after hydrate", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ attributionId: "attr_id_9" }), + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ ok: true }), + }); global.fetch = fetchMock as unknown as typeof fetch; TapAppLink.configure({ - publicKey: "etk_test", + publicKey: "etk_test_key", environment: "sandbox", ingestUrl: "https://example.test", }); + await TapAppLink.trackInstall(); + + const persisted = asyncStore.get(STORAGE_KEY)!; + await TapAppLink.resetForTesting(); + asyncStore.set(STORAGE_KEY, persisted); + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); await TapAppLink.setAppUserId("user_42"); expect(TapAppLink.getAppUserId()).toBe("user_42"); - expect(fetchMock).toHaveBeenCalledWith( - "https://example.test/ingestIdentify", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer etk_test", - }), - }), + expect(fetchMock).toHaveBeenCalledTimes(2); + const identifyBody = JSON.parse( + (fetchMock.mock.calls[1][1] as { body: string }).body, + ) as { appUserId: string; attributionId: string }; + expect(identifyBody).toEqual({ + appUserId: "user_42", + attributionId: "attr_id_9", + }); + }); + + it("redacts the API key in debug logs", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const fetchMock = jest.fn().mockResolvedValue({ + status: 200, + json: async () => ({ attributionId: "attr_dbg" }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + TapAppLink.configure({ + publicKey: "etk_live_supersecret", + environment: "sandbox", + ingestUrl: "https://example.test", + debug: true, + }); + await TapAppLink.trackInstall(); + + const logged = logSpy.mock.calls.map((call) => JSON.stringify(call)); + expect(logged.some((line) => line.includes("etk_live_supersecret"))).toBe( + false, ); + expect(logged.some((line) => line.includes("etk_...cret"))).toBe(true); + logSpy.mockRestore(); }); }); diff --git a/package-lock.json b/package-lock.json index ae82303..2f29bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@types/jest": "^29.5.14", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.46.0", @@ -21,8 +22,14 @@ "typescript": "^5.9.3" }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.17.0", "react": "*", "react-native": ">=0.72.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -2921,6 +2928,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -5737,6 +5757,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -6921,6 +6951,19 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index b25bbd7..60c1b74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "description": "Tap App Link attribution SDK for React Native and Expo", "license": "MIT", "homepage": "https://tapapplink.com", @@ -38,15 +38,21 @@ "test": "jest", "prepublishOnly": "npm run build" }, - "publishConfig": { "access": "public" }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.17.0", "react": "*", "react-native": ">=0.72.0" }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + }, "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@types/jest": "^29.5.14", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.46.0", diff --git a/src/index.ts b/src/index.ts index f1a36b1..6958e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export { type TapAppLinkEnvironment, type TapAppLinkOffer, } from "./tapAppLink"; +export type { PersistedInstallState } from "./storage"; export { TapAppLink as default } from "./tapAppLink"; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..f1ddb8f --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,120 @@ +export type PersistedInstallState = { + installId: string; + tracked: boolean; + attributionId?: string; + offer?: { + creatorName: string; + promoCode: string | null; + billingOfferId: string | null; + } | null; +}; + +export type KeyValueStorage = { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; +}; + +export const STORAGE_KEY = "@tapapplink/react-native/installState"; + +let memoryFallback: string | null = null; +let warnedMissingStorage = false; + +const memoryStorage = (): KeyValueStorage => ({ + getItem: async () => memoryFallback, + setItem: async (_key, value) => { + memoryFallback = value; + }, + removeItem: async () => { + memoryFallback = null; + }, +}); + +/** + * Prefer AsyncStorage so install state survives cold starts on both iOS and + * Android (Expo and bare). The Android native module alone cannot cover iOS. + */ +export const resolveStorage = (): KeyValueStorage => { + try { + // Optional peer; resolve at runtime so the SDK still loads without it. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("@react-native-async-storage/async-storage") as { + default?: KeyValueStorage; + } & KeyValueStorage; + const storage = mod.default ?? mod; + if ( + storage && + typeof storage.getItem === "function" && + typeof storage.setItem === "function" + ) { + return storage; + } + } catch { + // fall through + } + + if (!warnedMissingStorage) { + warnedMissingStorage = true; + console.warn( + "[TapAppLink] @react-native-async-storage/async-storage is not installed; install state will not persist across launches. Add it as a dependency for Expo and bare React Native.", + ); + } + return memoryStorage(); +}; + +export const loadInstallState = async ( + storage: KeyValueStorage, +): Promise => { + try { + const raw = await storage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as PersistedInstallState; + if (!parsed || typeof parsed.installId !== "string") return null; + return { + installId: parsed.installId, + tracked: Boolean(parsed.tracked), + attributionId: + typeof parsed.attributionId === "string" + ? parsed.attributionId + : undefined, + offer: parsed.offer ?? null, + }; + } catch { + return null; + } +}; + +export const saveInstallState = async ( + storage: KeyValueStorage, + state: PersistedInstallState, +): Promise => { + await storage.setItem(STORAGE_KEY, JSON.stringify(state)); +}; + +export const clearInstallState = async ( + storage: KeyValueStorage, +): Promise => { + memoryFallback = null; + await storage.removeItem(STORAGE_KEY); +}; + +export const resetStorageHelpersForTesting = () => { + memoryFallback = null; + warnedMissingStorage = false; +}; + +export const createInstallId = (): string => { + const cryptoObj = + typeof globalThis !== "undefined" + ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto + : undefined; + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + // RFC 4122 version 4 fallback when randomUUID is unavailable. + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const rand = (Math.random() * 16) | 0; + const value = char === "x" ? rand : (rand & 0x3) | 0x8; + return value.toString(16); + }); +}; diff --git a/src/tapAppLink.ts b/src/tapAppLink.ts index fd81a7c..945df78 100644 --- a/src/tapAppLink.ts +++ b/src/tapAppLink.ts @@ -1,4 +1,14 @@ import { NativeModules, Platform } from "react-native"; +import { + clearInstallState, + createInstallId, + loadInstallState, + resetStorageHelpersForTesting, + resolveStorage, + saveInstallState, + type KeyValueStorage, + type PersistedInstallState, +} from "./storage"; export type TapAppLinkEnvironment = "production" | "sandbox"; @@ -12,6 +22,8 @@ export type TapAppLinkConfig = { publicKey: string; environment: TapAppLinkEnvironment; ingestUrl?: string; + /** When true, logs requests, responses and stored state with the API key redacted. */ + debug?: boolean; }; type NativeSignals = { @@ -58,11 +70,69 @@ const collectSignals = async (): Promise => { }; let config: TapAppLinkConfig | null = null; +let storage: KeyValueStorage = resolveStorage(); +let hydratePromise: Promise | null = null; +let installId: string | undefined; let tracked = false; let lastAttributionId: string | undefined; let lastAppUserId: string | undefined; let lastOffer: TapAppLinkOffer | null = null; +const redactKey = (value: string): string => { + if (value.length <= 8) return "***"; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +}; + +const debugEnabled = (): boolean => Boolean(config?.debug); + +const debugLog = (message: string, details?: unknown) => { + if (!debugEnabled()) return; + if (details === undefined) { + console.log(`[TapAppLink] ${message}`); + return; + } + console.log(`[TapAppLink] ${message}`, details); +}; + +const applyPersisted = (state: PersistedInstallState) => { + installId = state.installId; + tracked = state.tracked; + lastAttributionId = state.attributionId; + lastOffer = state.offer ?? null; +}; + +const persist = async () => { + if (!installId) return; + const state: PersistedInstallState = { + installId, + tracked, + attributionId: lastAttributionId, + offer: lastOffer, + }; + await saveInstallState(storage, state); + debugLog("stored state", { + installId: state.installId, + tracked: state.tracked, + attributionId: state.attributionId, + offer: state.offer, + }); +}; + +const hydrate = async () => { + const loaded = await loadInstallState(storage); + if (loaded) { + applyPersisted(loaded); + debugLog("hydrated state", loaded); + } +}; + +const ensureHydrated = async () => { + if (!hydratePromise) { + hydratePromise = hydrate(); + } + await hydratePromise; +}; + const ingestBase = (cfg: TapAppLinkConfig): string => { if (cfg.ingestUrl) return cfg.ingestUrl.replace(/\/$/, ""); return "https://us-central1-tapapplink.cloudfunctions.net"; @@ -72,15 +142,28 @@ const post = async (path: string, body: unknown) => { if (!config) { throw new Error("TapAppLink.configure() must be called first"); } - const response = await fetch(`${ingestBase(config)}${path}`, { + const url = `${ingestBase(config)}${path}`; + const headers = { + Authorization: `Bearer ${config.publicKey}`, + "Content-Type": "application/json", + }; + debugLog("request", { + url, method: "POST", headers: { - Authorization: `Bearer ${config.publicKey}`, - "Content-Type": "application/json", + ...headers, + Authorization: `Bearer ${redactKey(config.publicKey)}`, }, + body, + }); + const response = await fetch(url, { + method: "POST", + headers, body: JSON.stringify(body), }); - return response.json() as Promise>; + const result = (await response.json()) as Record; + debugLog("response", { url, status: response.status, body: result }); + return result; }; const cacheFromResult = (result: Record) => { @@ -93,23 +176,48 @@ const cacheFromResult = (result: Record) => { } }; +const platformName = (): "IOS" | "ANDROID" | "UNKNOWN" => { + if (Platform.OS === "ios") return "IOS"; + if (Platform.OS === "android") return "ANDROID"; + return "UNKNOWN"; +}; + export const TapAppLink = { configure: (next: TapAppLinkConfig) => { config = next; + storage = resolveStorage(); + hydratePromise = hydrate(); + debugLog("configure", { + environment: next.environment, + ingestUrl: next.ingestUrl, + publicKey: redactKey(next.publicKey), + debug: Boolean(next.debug), + }); }, trackInstall: async () => { + await ensureHydrated(); if (tracked) { - return { matched: false, skipped: true }; + const stored = { + matched: false, + skipped: true as const, + installId, + attributionId: lastAttributionId, + offer: lastOffer, + }; + debugLog("trackInstall skipped; returning stored values", stored); + return stored; } + + if (!installId) { + installId = createInstallId(); + await persist(); + } + const signals = await collectSignals(); const result = await post("/ingestInstall", { - platform: - Platform.OS === "ios" - ? "IOS" - : Platform.OS === "android" - ? "ANDROID" - : "UNKNOWN", + installId, + platform: platformName(), deviceFamily: signals.deviceFamily, locale: signals.locale, networkContext: signals.networkContext, @@ -118,10 +226,12 @@ export const TapAppLink = { }); tracked = true; cacheFromResult(result); + await persist(); return result; }, setAppUserId: async (appUserId: string) => { + await ensureHydrated(); lastAppUserId = appUserId; return post("/ingestIdentify", { appUserId, @@ -130,24 +240,22 @@ export const TapAppLink = { }, applyCode: async (code: string) => { + await ensureHydrated(); const result = await post("/redeemCode", { code, appUserId: lastAppUserId, attributionId: lastAttributionId, - platform: - Platform.OS === "ios" - ? "IOS" - : Platform.OS === "android" - ? "ANDROID" - : "UNKNOWN", + platform: platformName(), }); cacheFromResult(result); + await persist(); return result; }, getOffer: () => lastOffer, getAttributionId: () => lastAttributionId, getAppUserId: () => lastAppUserId, + getInstallId: () => installId, linkRevenueCatUser: async (appUserId: string) => TapAppLink.setAppUserId(appUserId), @@ -158,10 +266,16 @@ export const TapAppLink = { linkQonversionUser: async (userId: string) => TapAppLink.setAppUserId(userId), resetForTesting: async () => { + config = null; tracked = false; + installId = undefined; lastAttributionId = undefined; lastAppUserId = undefined; lastOffer = null; + hydratePromise = null; + await clearInstallState(storage); + resetStorageHelpersForTesting(); + storage = resolveStorage(); }, }; From 39a368d8b6c977ea0eeb5077fe351761db41981e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 13:32:02 +0000 Subject: [PATCH 2/2] Clarify AsyncStorage install requirement and missing-store warning Document Expo and bare RN install commands that include AsyncStorage, and spell out that without it each cold launch posts a new ingestInstall. Co-authored-by: Kenny Ye --- README.md | 13 +++++++++++-- src/storage.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a6cb4ff..a561dea 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,20 @@ Works in Expo **dev clients and production builds**. Expo Go cannot load the And ## Install +Install the SDK and AsyncStorage together. AsyncStorage is required so install state survives cold launches; without it each launch can post a new install. + +**Expo** + ```bash -yarn add @tapapplink/react-native @react-native-async-storage/async-storage +npx expo install @tapapplink/react-native @react-native-async-storage/async-storage ``` -`@react-native-async-storage/async-storage` is an optional peer used to persist install state across cold launches on both iOS and Android. Without it, tracking still works for the current process only. +**Bare React Native** + +```bash +npm install @tapapplink/react-native @react-native-async-storage/async-storage +# or: yarn add @tapapplink/react-native @react-native-async-storage/async-storage +``` Bare React Native iOS apps should run `cd ios && pod install` after installing. diff --git a/src/storage.ts b/src/storage.ts index f1ddb8f..bec7ea5 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -56,7 +56,7 @@ export const resolveStorage = (): KeyValueStorage => { if (!warnedMissingStorage) { warnedMissingStorage = true; console.warn( - "[TapAppLink] @react-native-async-storage/async-storage is not installed; install state will not persist across launches. Add it as a dependency for Expo and bare React Native.", + "[TapAppLink] @react-native-async-storage/async-storage is not installed. Install state cannot persist, so each cold launch will post a new /ingestInstall until you add AsyncStorage.", ); } return memoryStorage();