Skip to content

feat(unified-react-native): add unified-react-native support - #1945

Open
Mercy811 wants to merge 11 commits into
mainfrom
codex/sdkrn-61-unified-rn
Open

feat(unified-react-native): add unified-react-native support#1945
Mercy811 wants to merge 11 commits into
mainfrom
codex/sdkrn-61-unified-rn

Conversation

@Mercy811

@Mercy811 Mercy811 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add the new @amplitude/unified-react-native beta package.
  • Initialize Analytics, Experiment, Session Replay, and Guides and Surveys through initAll.
  • Share serverZone, instanceName, and translated logLevel defaults while allowing blade-specific overrides.
  • Expose Experiment, Session Replay, and Guides and Surveys accessors alongside the React Native Analytics API.
  • Ship a React Native CLI autolinking preset so applications directly install only the unified package.
  • Add a bare React Native example that consumes the one-package preset and exercises every blade.
  • Add package documentation, build configuration, and full unit coverage.

Stacked on #1944, which provides @amplitude/plugin-experiment-react-native.

Local test with the example app examples/unified/react-native-app. Able to see requests from all blades.
image

Testing

  • pnpm install --frozen-lockfile
  • Unified React Native build, typecheck, lint, and tests (18 tests, 100% coverage)
  • Unified example lint and typecheck
  • React Native CLI config resolves all five transitive native dependencies for iOS and Android
  • Android :app:generateAutolinkingPackageList
  • iOS pod install (five native modules plus Engagement and AsyncStorage codegen)
  • Production iOS Metro bundle
  • pnpm docs:check
  • pnpm lint
  • pnpm lint:deps
  • pnpm test (30 projects)
  • pnpm build — the new package and its dependencies build successfully, but the repository-wide command stalls in the unrelated @amplitude/segment-session-replay-plugin Rollup task
  • pnpm test:examples — the base branch references a missing root jest.setup.examples.js

Checklist

  • PR title follows the conventional commit format
  • Breaking change: No

Note

Medium Risk
New beta SDK orchestrates multi-blade init and native autolinking for customer apps; behavior is well-tested but affects integration surfaces (RN ≥0.76, New Architecture for Engagement).

Overview
Introduces @amplitude/unified-react-native (1.0.0-beta.0) as a single install that bundles Analytics, Experiment, Session Replay, and Guides and Surveys. A new init() path initializes Analytics, registers blade plugins, starts Experiment, and boots Engagement, with shared serverZone, instanceName, and logLevel defaults and per-blade overrides. Initialization is idempotent; failed init can retry after removing partially added blades.

The package ships react-native.config.js, an autolinking preset that exposes five transitive native modules (analytics, experiment, engagement, session replay, AsyncStorage) so apps only depend on the unified package. Public API re-exports Analytics helpers plus experiment() / sessionReplay() accessors, AmpMaskView, and a library enrichment plugin for event metadata.

Adds a bare React Native 0.76 example under examples/unified/react-native-app that loads the preset, enables Android New Architecture, dedupes Metro resolution for pnpm workspaces, and demonstrates manual init() via a button (API key from root .env). Includes unit tests for the client factory, autolinking preset, and library plugin.

Reviewed by Cursor Bugbot for commit 8358dca. Configure here.

@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

SDKRN-61

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 64.28 KB (0%)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.97 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 218.39 KB (0%)
@amplitude/element-selector (gzipped esm) 3.4 KB (0%)

@Mercy811
Mercy811 force-pushed the codex/sdkrn-61-unified-rn branch 2 times, most recently from 01c6537 to 5f61309 Compare August 24, 2026 18:27
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Maestro asserts missing UI buttons
    • Updated smoke.yaml to assert the single "Initialize all SDKs" button that App.tsx renders instead of the removed multi-button labels.

Create PR

You can send follow-ups to the cloud agent here.

Comment thread examples/unified/react-native-app/.maestro/smoke.yaml Outdated
@Mercy811
Mercy811 force-pushed the codex/sdkrn-61-unified-rn branch from 2be24e8 to a1cbf5c Compare August 24, 2026 22:07
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Session Replay double-started
    • Removed the redundant sessionReplay.start() call after add since the plugin already auto-starts during setup when autoStart is true.
  • ✅ Fixed: Re-init skips blade teardown
    • Re-init now awaits teardown on Experiment and Session Replay plugins and creates fresh instances instead of reusing stale blade objects via ??=.

Create PR

Or push these changes by commenting:

@cursor push 867ed7360f
Preview (867ed7360f)
diff --git a/packages/unified-react-native/src/unified-client-factory.ts b/packages/unified-react-native/src/unified-client-factory.ts
--- a/packages/unified-react-native/src/unified-client-factory.ts
+++ b/packages/unified-react-native/src/unified-client-factory.ts
@@ -79,7 +79,8 @@
       }
       hasInitialized = true;
 
-      experiment ??= experimentPlugin({
+      await experiment?.teardown?.();
+      experiment = experimentPlugin({
         ...getSharedExperimentOptions(unifiedOptions),
         ...unifiedOptions?.experiment,
       });
@@ -91,16 +92,14 @@
         await experimentClient.start();
       }
 
-      sessionReplay ??= new SessionReplayPlugin({
+      await sessionReplay?.teardown?.();
+      sessionReplay = new SessionReplayPlugin({
         ...getSharedSessionReplayOptions(unifiedOptions),
         ...unifiedOptions?.sessionReplay,
       });
       await analyticsClient.add(sessionReplay).promise;
-      if (sessionReplay.sessionReplayConfig.autoStart) {
-        await sessionReplay.start();
-      }
 
-      engagement ??= getPlugin({
+      engagement = getPlugin({
         ...getSharedEngagementOptions(unifiedOptions),
         ...unifiedOptions?.engagement,
       });

diff --git a/packages/unified-react-native/test/unified-client-factory.test.ts b/packages/unified-react-native/test/unified-client-factory.test.ts
--- a/packages/unified-react-native/test/unified-client-factory.test.ts
+++ b/packages/unified-react-native/test/unified-client-factory.test.ts
@@ -16,19 +16,27 @@
     config,
     sessionReplayConfig: { autoStart: config?.autoStart ?? true },
     start: jest.fn(() => Promise.resolve()),
+    teardown: jest.fn(() => Promise.resolve()),
   })),
 }));
 
-jest.mock('@amplitude/plugin-engagement-react-native', () => ({
-  boot: jest.fn(() => Promise.resolve()),
-  getPlugin: jest.fn().mockImplementation((config) => ({ name: 'engagement', config })),
-}));
+jest.mock('@amplitude/plugin-engagement-react-native', () => {
+  let plugin: { name: string; config: unknown } | undefined;
+  return {
+    boot: jest.fn(() => Promise.resolve()),
+    getPlugin: jest.fn().mockImplementation((config) => {
+      plugin ??= { name: 'engagement', config };
+      return plugin;
+    }),
+  };
+});
 
 jest.mock('@amplitude/plugin-experiment-react-native', () => ({
   experimentPlugin: jest.fn().mockImplementation((config) => ({
     name: 'experiment',
     config,
     experiment: { start: jest.fn(), variant: jest.fn() },
+    teardown: jest.fn(() => Promise.resolve()),
   })),
 }));
 
@@ -88,8 +96,6 @@
       deploymentKey: 'deployment-key',
     });
     expect(MockSessionReplayPlugin).toHaveBeenCalledWith({ logLevel: LogLevel.Debug, sampleRate: 0.5 });
-    const sessionReplayStart = (client.sessionReplay() as unknown as { start: jest.Mock }).start;
-    expect(sessionReplayStart).toHaveBeenCalledTimes(1);
     expect(mockGetPlugin).toHaveBeenCalledWith({ serverZone: 'EU', logLevel: 'debug', locale: 'fr-FR' });
     expect(mockBoot).toHaveBeenCalledWith('user-id', 'device-id');
     const experimentStart = (client.experiment() as unknown as { start: jest.Mock }).start;
@@ -179,7 +185,7 @@
     expect(mockGetPlugin).toHaveBeenCalledWith({});
   });
 
-  test('reuses blade instances on sequential initialization', async () => {
+  test('creates fresh blade instances on sequential initialization', async () => {
     const client = createInstance();
 
     await client.init('first-api-key');
@@ -189,11 +195,11 @@
     await client.init('second-api-key');
 
     expect(analyticsInit).toHaveBeenCalledTimes(2);
-    expect(mockExperimentPlugin).toHaveBeenCalledTimes(1);
-    expect(MockSessionReplayPlugin).toHaveBeenCalledTimes(1);
-    expect(mockGetPlugin).toHaveBeenCalledTimes(1);
-    expect(client.sessionReplay()).toBe(sessionReplay);
-    expect(client.experiment()).toBe(experiment);
+    expect(mockExperimentPlugin).toHaveBeenCalledTimes(2);
+    expect(MockSessionReplayPlugin).toHaveBeenCalledTimes(2);
+    expect(mockGetPlugin).toHaveBeenCalledTimes(2);
+    expect(client.sessionReplay()).not.toBe(sessionReplay);
+    expect(client.experiment()).not.toBe(experiment);
     expect(client.engagement()).toBe(engagement);
   });

You can send follow-ups to the cloud agent here.

Comment thread packages/unified-react-native/src/unified-client-factory.ts
Comment thread packages/unified-react-native/src/unified-client-factory.ts Outdated
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Personal Xcode development team
    • Removed the personal DEVELOPMENT_TEAM (7CA8BGS9U9) from both Debug and Release build configurations in the unified React Native example iOS project.
  • ✅ Fixed: Failed init permanently blocks retries
    • Clear initPromise on initialization failure via a catch handler so subsequent init() calls can retry, while keeping the resolved promise cached after success.

Create PR

Or push these changes by commenting:

@cursor push 53f0a436f8
Preview (53f0a436f8)
diff --git a/examples/unified/react-native-app/ios/app.xcodeproj/project.pbxproj b/examples/unified/react-native-app/ios/app.xcodeproj/project.pbxproj
--- a/examples/unified/react-native-app/ios/app.xcodeproj/project.pbxproj
+++ b/examples/unified/react-native-app/ios/app.xcodeproj/project.pbxproj
@@ -474,7 +474,6 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
 				CURRENT_PROJECT_VERSION = 1;
-				DEVELOPMENT_TEAM = 7CA8BGS9U9;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = app/Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -503,7 +502,6 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
 				CURRENT_PROJECT_VERSION = 1;
-				DEVELOPMENT_TEAM = 7CA8BGS9U9;
 				INFOPLIST_FILE = app/Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

diff --git a/packages/unified-react-native/src/unified-client-factory.ts b/packages/unified-react-native/src/unified-client-factory.ts
--- a/packages/unified-react-native/src/unified-client-factory.ts
+++ b/packages/unified-react-native/src/unified-client-factory.ts
@@ -56,7 +56,7 @@
       return initPromise;
     }
 
-    initPromise = (async () => {
+    const promise = (async () => {
       const analyticsOptions: ReactNativeOptions = {
         ...getSharedAnalyticsOptions(unifiedOptions),
         ...unifiedOptions?.analytics,
@@ -97,6 +97,11 @@
       await bootEngagement(analyticsClient.getUserId(), analyticsClient.getDeviceId());
     })();
 
+    initPromise = promise.catch((error) => {
+      initPromise = undefined;
+      throw error;
+    });
+
     return initPromise;
   };

You can send follow-ups to the cloud agent here.

Comment thread examples/unified/react-native-app/ios/app.xcodeproj/project.pbxproj Outdated
Comment thread packages/unified-react-native/src/unified-client-factory.ts
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Library plugin lost after failed retry
    • Track whether analytics init was previously attempted and re-add the library plugin after init on retries, so timeline.reset() no longer drops it when recovery succeeds.

Create PR

Or push these changes by commenting:

@cursor push d2a4bf6210
Preview (d2a4bf6210)
diff --git a/packages/unified-react-native/src/unified-client-factory.ts b/packages/unified-react-native/src/unified-client-factory.ts
--- a/packages/unified-react-native/src/unified-client-factory.ts
+++ b/packages/unified-react-native/src/unified-client-factory.ts
@@ -56,6 +56,7 @@
   let sessionReplay: SessionReplayPlugin | undefined;
   let engagement: ReturnType<typeof getPlugin> | undefined;
   let hasInitializedAnalytics = false;
+  let hasAttemptedAnalyticsInit = false;
   let initPromise: Promise<void> | undefined;
 
   const init = (apiKey: string, unifiedOptions?: UnifiedOptions): Promise<void> => {
@@ -74,6 +75,8 @@
       }
       analyticsOptions.loggerProvider = loggerProvider;
 
+      const shouldAddLibraryPluginAfterInit = hasInitializedAnalytics || hasAttemptedAnalyticsInit;
+
       if (hasInitializedAnalytics) {
         for (const blade of [experiment, sessionReplay, engagement]) {
           if (blade !== undefined) {
@@ -83,13 +86,14 @@
         experiment = undefined;
         sessionReplay = undefined;
         engagement = undefined;
-      } else {
+      } else if (!shouldAddLibraryPluginAfterInit) {
         analyticsClient.add(libraryPlugin());
       }
 
+      hasAttemptedAnalyticsInit = true;
       await analyticsClient.init(apiKey, analyticsOptions.userId, analyticsOptions).promise;
 
-      if (hasInitializedAnalytics) {
+      if (shouldAddLibraryPluginAfterInit) {
         await analyticsClient.add(libraryPlugin()).promise;
       }
       hasInitializedAnalytics = true;

You can send follow-ups to the cloud agent here.

Comment thread packages/unified-react-native/src/unified-client-factory.ts Outdated
Base automatically changed from codex/sdkrn-60-experiment-plugin to main August 25, 2026 17:47
@Mercy811
Mercy811 force-pushed the codex/sdkrn-61-unified-rn branch from 4e4cdf1 to 8358dca Compare August 25, 2026 17:47
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8358dca. Configure here.

@Mercy811
Mercy811 marked this pull request as ready for review August 26, 2026 21:00
@Mercy811
Mercy811 requested a review from a team as a code owner August 26, 2026 21:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8358dca6a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/unified-react-native/src/unified-client-factory.ts Outdated
if (experimentClient === undefined) {
loggerProvider.debug(`${initializedExperiment.name} plugin is not initialized.`);
} else {
await experimentClient.start();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice that all of the blades are being initialized sequentially. Analytics initializes, and then Experiment, and then Session Replay. It seems like we may have some unnecessary idle time (especially here at await experimentClient.start()).

For each blade, could we put the awaits into a promise, and then run Promise.all at the end?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could look like this...

async function initExperiment() {
    try {
            const initializedExperiment = experimentPlugin({
              ...getSharedExperimentOptions(unifiedOptions),
              ...unifiedOptions?.experiment,
            });
            await analyticsClient.add(initializedExperiment).promise;
            experiment = initializedExperiment;
    
            const experimentClient = initializedExperiment.experiment;
            if (experimentClient === undefined) {
              loggerProvider.debug(`${initializedExperiment.name} plugin is not initialized.`);
            } else {
              await experimentClient.start();
         }
    } catch (error) {
      logInitializationError(loggerProvider, 'Experiment', error);
    }
}

//....

await Promise.all([
  initAnalytics(),
  initExperiment(),
]);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants