Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
33d47ec
fix(analytics-react-native): lazy-load AsyncStorage so customers can …
Mercy811 May 22, 2026
43d050f
fix(analytics-react-native): address review feedback on lazy-load (SD…
Mercy811 May 22, 2026
50d4393
fix(analytics-react-native): address Copilot review on lazy-load (SDK…
Mercy811 May 22, 2026
a7cd85c
docs(analytics-react-native): clarify cookieStorage override in opt-o…
Mercy811 May 23, 2026
20bbead
fix(analytics-react-native): tighten MODULE_NOT_FOUND check to our pa…
Mercy811 May 23, 2026
0443138
test(react-native-example): exercise AsyncStorage opt-out path in Mae…
Mercy811 May 25, 2026
268f1a7
style(react-native-example): satisfy prettier in react-native.config.…
Mercy811 May 25, 2026
bcb137e
docs(react-native-example): correct Maestro comment about package.jso…
Mercy811 May 25, 2026
354be47
test(react-native-example): wait for toast dismiss between Maestro ta…
Mercy811 May 25, 2026
64dd46e
fix(react-native-example): show toast synchronously on tap to fix CI …
Mercy811 May 25, 2026
f2e8b41
docs(analytics-react-native): use 'storage client' instead of 'storag…
cursoragent May 25, 2026
6774244
docs(analytics-react-native): clarify userId arg can be undefined or …
cursoragent May 25, 2026
a9deb57
docs(analytics-react-native): move AsyncStorage opt-out section to do…
Mercy811 May 25, 2026
4f39d53
refactor(analytics-react-native): use null sentinel for lazy AsyncSto…
Mercy811 May 25, 2026
72fc413
test(react-native-example): use customer-shaped opt-out recipe in smo…
Mercy811 May 26, 2026
d99b912
fix(react-native-example): remove stray '>' that broke smoke.yaml YAM…
Mercy811 May 26, 2026
90ab3a6
feat(react-native-example): inject AMPLITUDE_API_KEY at bundle time (…
Mercy811 May 26, 2026
9e1ad1c
style(react-native-example): satisfy prettier in babel.config.js (SDK…
Mercy811 May 26, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/rn-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ jobs:
# Raw xcodebuild output is noisy; xcpretty would be nicer but isn't
# installed by default on macos-14 runners, and adding it as a Gemfile
# dependency for cosmetics isn't worth the install time.
#
# AMPLITUDE_API_KEY is read by babel-plugin-transform-inline-environment-variables
# at bundle time (see examples/react-native/app/babel.config.js) and inlined into
# the JS bundle so the example app can send events end-to-end to api2.amplitude.com
# under our test project. Mirrors the Playwright VITE_AMPLITUDE_API_KEY pattern in
# .github/actions/e2e-test/action.yml. With no secret, the bundle falls back to
# the literal 'YOUR_API_KEY' and the Maestro test still passes the crash-guard
# assertions (only the api2 round-trip is skipped).
env:
AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_API_KEY }}
run: |
xcodebuild \
-workspace ios/app.xcworkspace \
Expand Down
52 changes: 52 additions & 0 deletions examples/react-native/app/.maestro/smoke.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,59 @@
appId: org.reactjs.native.example.app
---
# Regression guard for SDKRN-8.
#
# This example app exercises the documented AsyncStorage opt-out recipe:
#
# 1. @react-native-async-storage/async-storage is NOT declared in this app's
# package.json. In a pnpm setup, that means there is no top-level
# node_modules/@react-native-async-storage/async-storage symlink; the JS
# package only exists nested under .pnpm/..., where RN's autolinking
# doesn't walk. Pod install therefore leaves RNCAsyncStorage out of the
# iOS binary (verifiable: `grep -c RNCAsyncStorage ios/Podfile.lock` = 0).
#
# 2. App.tsx overrides both storageProvider AND cookieStorage with an
# in-memory implementation. With both slots overridden, the SDK never
# reaches its default LocalStorage fallback chain — so getAsyncStorage()
# is never called, the lazy require to @react-native-async-storage is
# never triggered, and the package's top-level NativeModule-null throw
# never fires.
#
# If the SDK regresses to its old static `import AsyncStorage` (i.e. anything
# that requires @react-native-async-storage/async-storage at module-load),
# the app crashes at boot with:
# Unhandled JS Exception: [@RNC/AsyncStorage]: NativeModule: AsyncStorage is null
# In that case the title below would never render and this flow fails.
#
# We also tap both Track buttons to exercise the SDK's runtime storage paths
# (event queue + identity update) under the opted-out configuration. App.tsx
# shows a toast labelled "Amplitude Response" *synchronously* on each tap, so
# the assertion below verifies the SDK call returned without throwing — without
# depending on the SDK's promise resolution (which only fires when the event
# is flushed to api2.amplitude.com, and that's slow / unreachable in CI).
- launchApp
- extendedWaitUntil:
visible:
text: "Test Amplitude App"
timeout: 15000
- tapOn: "Track Event"
- extendedWaitUntil:
visible:
text: "Amplitude Response"
timeout: 10000
- assertVisible:
text: "Test Amplitude App"
# Wait for the first toast to dismiss before tapping the next button, so the
# second `extendedWaitUntil: Amplitude Response` actually waits for the new
# toast rather than passing immediately on the lingering one. react-native-
# toast-message auto-dismisses after ~4s.
- extendedWaitUntil:
notVisible:
text: "Amplitude Response"
timeout: 10000
- tapOn: "Track Identify"
- extendedWaitUntil:
visible:
text: "Amplitude Response"
timeout: 10000
- assertVisible:
text: "Test Amplitude App"
75 changes: 67 additions & 8 deletions examples/react-native/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,65 @@ import {
identify,
init,
track,
Types,
} from '@amplitude/analytics-react-native';

import {Colors, Header} from 'react-native/Libraries/NewAppScreen';

// Regression guard for SDKRN-8: this example app deliberately exercises the
// SDK's "opt out of AsyncStorage" path. AsyncStorage is excluded from native
// autolinking (see react-native.config.js) and the SDK's two storage slots
// are overridden with the in-memory implementation below. The SDK's lazy
// require of @react-native-async-storage/async-storage must not crash at
// module-load, and track/identify must keep working with persistence in
// memory only.
class InMemoryStorage<T> implements Types.Storage<T> {
private store = new Map<string, T>();

async isEnabled(): Promise<boolean> {
return true;
}

async get(key: string): Promise<T | undefined> {
return this.store.get(key);
}

async getRaw(key: string): Promise<string | undefined> {
const value = this.store.get(key);
return value === undefined ? undefined : JSON.stringify(value);
}

async set(key: string, value: T): Promise<void> {
this.store.set(key, value);
}

async remove(key: string): Promise<void> {
this.store.delete(key);
}

async reset(): Promise<void> {
this.store.clear();
}
}

// Module-scope init mirrors the reproduce pattern from issue #181 — the SDK's
// full module graph loads before any component renders, so any top-level
// crash in the SDK (e.g. CJS circular-dep regression) surfaces on app launch.
init('YOUR_API_KEY');
// crash in the SDK (e.g. CJS circular-dep regression, or the static
// AsyncStorage import from before SDKRN-8) surfaces on app launch.
//
// Note: init's signature is (apiKey, userId, options). Pass `undefined` for
// userId so the storage overrides land in the options slot — otherwise they
// silently get bound to userId and the SDK falls back to the default storage
// chain (which then tries to use AsyncStorage and throws at runtime).
// AMPLITUDE_API_KEY is inlined at bundle time by
// babel-plugin-transform-inline-environment-variables (see babel.config.js).
// CI provides the value from a GitHub secret on the xcodebuild step; local
// devs can `export AMPLITUDE_API_KEY=…` (or use direnv) before running
// pnpm ios. With no env set, the fallback keeps the example app self-contained.
init(process.env.AMPLITUDE_API_KEY || 'YOUR_API_KEY', undefined, {
storageProvider: new InMemoryStorage(),
cookieStorage: new InMemoryStorage(),
});

function App(): React.JSX.Element {
const isDarkMode = useColorScheme() === 'dark';
Expand All @@ -46,16 +97,24 @@ function App(): React.JSX.Element {
});
};

// Show a toast immediately on tap so the Maestro regression guard can
// verify the SDK call didn't crash without depending on network timing
// (the SDK's `.promise` only resolves once the event is actually flushed
// to api2.amplitude.com — which is slow / unreachable in CI runners).
// The SDK's eventual response is still surfaced in a second toast for
// local-dev visibility.
const trackEventAndShowToast = (eventName: string) => {
track(eventName).promise.then(e => {
showToast(e.message);
});
showToast(`Track Event called: ${eventName}`);
track(eventName)
.promise.then(e => showToast(e.message))
.catch(e => showToast(`Error: ${String(e)}`));
};

const trackIdentifyAndShowToast = () => {
identify(new Identify().set('react-native-test', 'yes')).promise.then(e => {
showToast(e.message);
});
showToast('Track Identify called');
identify(new Identify().set('react-native-test', 'yes'))
.promise.then(e => showToast(e.message))
.catch(e => showToast(`Error: ${String(e)}`));
};

return (
Expand Down
12 changes: 12 additions & 0 deletions examples/react-native/app/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
// Mirrors the .env / VITE_AMPLITUDE_API_KEY pattern in
// .github/actions/e2e-test/action.yml — inlines AMPLITUDE_API_KEY from
// the build-time shell environment into the JS bundle. CI passes the
// GitHub secret as an env to the xcodebuild step; local devs can
// `export AMPLITUDE_API_KEY=…` (or use direnv) before running pnpm ios.
// With no env var set, App.tsx falls back to the literal 'YOUR_API_KEY'.
[
'transform-inline-environment-variables',
{include: ['AMPLITUDE_API_KEY']},
],
],
};
10 changes: 2 additions & 8 deletions examples/react-native/app/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- amplitude-react-native (1.5.53):
- amplitude-react-native (1.5.54):
- React-Core
- boost (1.83.0)
- DoubleConversion (1.1.6)
Expand Down Expand Up @@ -1166,8 +1166,6 @@ PODS:
- React-logger (= 0.74.1)
- React-perflogger (= 0.74.1)
- React-utils (= 0.74.1)
- RNCAsyncStorage (1.24.0):
- React-Core
- SocketRocket (0.7.0)
- Yoga (0.0.0)

Expand Down Expand Up @@ -1228,7 +1226,6 @@ DEPENDENCIES:
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)

SPEC REPOS:
Expand Down Expand Up @@ -1345,13 +1342,11 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/utils"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
amplitude-react-native: ca773f7d7529a797536d346380ffef0569faf280
amplitude-react-native: 63743b22ffb128178b277abe575ac7439c84f215
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
FBLazyVector: 898d14d17bf19e2435cafd9ea2a1033efe445709
Expand Down Expand Up @@ -1405,7 +1400,6 @@ SPEC CHECKSUMS:
React-runtimescheduler: 87b14969bb0b10538014fb8407d472f9904bc8cd
React-utils: 67574b07bff4429fd6c4d43a7fad8254d814ee20
ReactCommon: 64c64f4ae1f2debe3fab1800e00cb8466a4477b7
RNCAsyncStorage: b6410dead2732b5c72a7fdb1ecb5651bbcf4674b
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372

Expand Down
2 changes: 1 addition & 1 deletion examples/react-native/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
},
"dependencies": {
"@amplitude/analytics-react-native": "workspace:*",
"@react-native-async-storage/async-storage": "^1.23.1",
"react": "18.2.0",
"react-native": "0.74.1",
"react-native-toast-message": "^2.2.0"
Expand All @@ -27,6 +26,7 @@
"@types/react": "^18.2.6",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
Expand Down
93 changes: 88 additions & 5 deletions packages/analytics-react-native/src/storage/local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,71 @@
import { Storage, getGlobalScope } from '@amplitude/analytics-core';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface AsyncStorageLike {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
}

// Three sentinel states:
// undefined - not tried yet
// null - tried, package isn't available (don't retry)
// object - tried, succeeded
//
// Resolve AsyncStorage lazily so the module can be opted out of via custom
// `storageProvider` + `react-native.config.js` autolinking exclusion without
// the SDK throwing at module-load time.
//
// The result is cached for the app lifetime: `require()` is synchronous in
// React Native and the module registry is stable, so retrying after a failed
// resolution would never produce a different result. Caching the failure as
// `null` lets us skip re-running `require()` on every storage call — Metro
// doesn't cache failed module resolutions, so without this we'd re-throw on
// every storage call. See https://nodejs.org/api/modules.html#requirecache
// for the success-case caching that `require()` gives us for free.
let asyncStorage: AsyncStorageLike | null | undefined = undefined;

const getAsyncStorage = (): AsyncStorageLike | null | undefined => {
if (asyncStorage !== undefined) {
return asyncStorage;
}
/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
try {
const mod = require('@react-native-async-storage/async-storage');
Comment thread
Mercy811 marked this conversation as resolved.
// Handles both ES-module (`{ default: AsyncStorage }`) and direct-export
// shapes — e.g. `jest.mock(..., () => mockAsyncStorage)` returns the mock
// directly without a `default` wrapper. The outer `?? null` ensures we
// never cache a falsy success value, which would be misread as "not tried".
asyncStorage = mod?.default ?? mod ?? null;
} catch (e) {
asyncStorage = null;
// Only swallow "this exact package is not installed" silently — that's
// the supported opt-out path. Anything else — including a `MODULE_NOT_FOUND`
// that's actually about a transitive dependency, or syntax/eval errors in
// the package itself — should be surfaced so it can be diagnosed instead of
// silently degrading to in-memory storage.
const code = (e as NodeJS.ErrnoException | undefined)?.code;
const message = e instanceof Error ? e.message : '';
const ourPackageMissing =
code === 'MODULE_NOT_FOUND' && message.includes('@react-native-async-storage/async-storage');
if (!ourPackageMissing) {
// eslint-disable-next-line no-console
console.warn('[Amplitude] Failed to load @react-native-async-storage/async-storage; persistence is disabled.', e);
}
}
/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
return asyncStorage;
};

export class LocalStorage<T> implements Storage<T> {
async isEnabled(): Promise<boolean> {
/* istanbul ignore if */
if (!getGlobalScope()) {
return false;
}
if (!getAsyncStorage()) {
return false;
}

const random = String(Date.now());
const testStorage = new LocalStorage<string>();
Expand Down Expand Up @@ -38,28 +97,52 @@ export class LocalStorage<T> implements Storage<T> {
}

async getRaw(key: string): Promise<string | undefined> {
return (await AsyncStorage.getItem(key)) || undefined;
const storage = getAsyncStorage();
if (!storage) {
return undefined;
}
try {
return (await storage.getItem(key)) || undefined;
} catch {
// AsyncStorage's JS package is resolvable but the native bridge is null
// (e.g. customer excluded it via autolinking but the JS package is still
// in node_modules). Callers like `parseOldCookies` consume `getRaw`
// directly without their own try/catch, so we must not propagate.
return undefined;
}
}

async set(key: string, value: T): Promise<void> {
const storage = getAsyncStorage();
if (!storage) {
return;
}
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
await storage.setItem(key, JSON.stringify(value));
} catch {
//
}
}

async remove(key: string): Promise<void> {
const storage = getAsyncStorage();
if (!storage) {
return;
}
try {
await AsyncStorage.removeItem(key);
await storage.removeItem(key);
} catch {
//
}
}

async reset(): Promise<void> {
const storage = getAsyncStorage();
if (!storage) {
return;
}
try {
await AsyncStorage.clear();
await storage.clear();
} catch {
//
}
Expand Down
Loading
Loading