From d0a24ac1986df59034ad5165777e905a2aea8e42 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 14:06:13 -0500 Subject: [PATCH 1/6] feat(react-native): add setAppLanguage() for $app_language Stamp the language the app displays on JS events and forward it to @posthog/react-native-plugin when the method exists, so native events agree. --- .changeset/rn-set-app-language.md | 5 + ...osthog-react-native-references-latest.json | 33 ++++ .../src/optional/OptionalPlugin.ts | 1 + packages/react-native/src/posthog-rn.ts | 41 +++++ .../react-native/test/app-language.spec.ts | 162 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 .changeset/rn-set-app-language.md create mode 100644 packages/react-native/test/app-language.spec.ts diff --git a/.changeset/rn-set-app-language.md b/.changeset/rn-set-app-language.md new file mode 100644 index 0000000000..b33f5ecabb --- /dev/null +++ b/.changeset/rn-set-app-language.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': minor +--- + +Add `setAppLanguage()` to send the language your app displays as `$app_language` on every event diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index 36586476f9..b1c17dcbbf 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -1132,6 +1132,35 @@ }, "path": "dist/posthog-rn.d.ts" }, + { + "category": "Capture", + "description": "Sets the `$app_language` property: the language your app displays, sent with every event.\nDefault: not set, so events carry no `$app_language`. React Native apps usually translate in JavaScript (for example with i18next), which the SDK can't see, so call this at startup and whenever the language changes. Compare `$locale`, which is the device language.\nThe value is kept in memory only and survives `reset()`. A `$app_language` you `register()` wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK captures itself, such as crashes and push opens, carry it too.", + "details": null, + "id": "setAppLanguage", + "showDocs": true, + "title": "setAppLanguage", + "examples": [ + { + "id": "", + "name": "", + "code": "\n\nposthog.setAppLanguage('de')\n// after the user switches language\nposthog.setAppLanguage(i18n.language)\n\n\n\n" + } + ], + "releaseTag": "public", + "params": [ + { + "description": "A BCP 47 language tag, such as `de` or `pt-BR`. `_` is replaced with `-`. Pass `null` or an empty string to stop sending it.", + "isOptional": false, + "type": "string | null", + "name": "language" + } + ], + "returnType": { + "id": "void", + "name": "void" + }, + "path": "dist/posthog-rn.d.ts" + }, { "category": "Feature flags", "description": "Sets group properties for feature flag evaluation.\nThese properties are automatically attached to the current group (set via posthog.group()). When you change the group, these properties are reset.", @@ -4203,6 +4232,10 @@ { "type": "((provider: PostHogPushIdentityProvider) => void) & ((provider: PostHogPushIdentityProvider) => void)", "name": "setPushIdentityProvider" + }, + { + "type": "(language: string | null) => Promise", + "name": "setAppLanguage" } ], "path": "dist/optional/OptionalPlugin.d.ts" diff --git a/packages/react-native/src/optional/OptionalPlugin.ts b/packages/react-native/src/optional/OptionalPlugin.ts index 9a68f511f6..7188ccd745 100644 --- a/packages/react-native/src/optional/OptionalPlugin.ts +++ b/packages/react-native/src/optional/OptionalPlugin.ts @@ -28,6 +28,7 @@ export type PostHogReactNativePluginExtended = typeof PostHogReactNativePlugin & captureFatalException?: (distinctId: string, timestamp: string, properties: { [key: string]: any }) => Promise getSessionReplayDebugProperties?: () => Promise<{ [key: string]: any }> markFatalExceptionHandled?: (message: string) => Promise + setAppLanguage?: (language: string | null) => Promise } export type OptionalPluginLoaders = { diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 2639c2a589..2ad27418bd 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -363,6 +363,7 @@ export class PostHog extends PostHogCore { private _eventsStorage: PostHogRNStorage private _logsStorage: PostHogRNStorage private _appProperties: PostHogCustomAppProperties = {} + private _appLanguage: string | undefined private _currentSessionId?: string | undefined private _enableSessionReplay?: boolean private _sessionReplayNativeInitialized: boolean = false @@ -866,6 +867,10 @@ export class PostHog extends PostHogCore { return { ...super.getCommonEventProperties(), ...this._appProperties, + // A registered $app_language wins, as on the native SDKs. Common properties override + // registered ones, so skip ours rather than overwrite it. + ...(this._appLanguage !== undefined && + this.props.$app_language === undefined && { $app_language: this._appLanguage }), $screen_height: height, $screen_width: width, } @@ -1080,6 +1085,37 @@ export class PostHog extends PostHogCore { return this._overrideDisplayLanguage } + /** + * Sets the `$app_language` property: the language your app displays, sent with every event. + * + * Default: not set, so events carry no `$app_language`. React Native apps usually translate in + * JavaScript (for example with i18next), which the SDK can't see, so call this at startup and + * whenever the language changes. Compare `$locale`, which is the device language. + * + * The value is kept in memory only and survives `reset()`. A `$app_language` you `register()` + * wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK + * captures itself, such as crashes and push opens, carry it too. + * + * {@label Capture} + * + * @example + * ```js + * posthog.setAppLanguage('de') + * // after the user switches language + * posthog.setAppLanguage(i18n.language) + * ``` + * + * @public + * + * @param language A BCP 47 language tag, such as `de` or `pt-BR`. `_` is replaced with `-`. + * Pass `null` or an empty string to stop sending it. + */ + setAppLanguage(language: string | null): void { + const normalized = typeof language === 'string' ? language.trim().replace(/_/g, '-') : '' + this._appLanguage = normalized || undefined + void this._enqueueNative('setAppLanguage', (plugin) => plugin.setAppLanguage?.(this._appLanguage ?? null)) + } + /** * Registers super properties that are sent with every event. * @@ -3392,6 +3428,11 @@ export class PostHog extends PostHogCore { // earlier optIn() keeps winning and sdkOptions.optOut alone can't opt a user back out. // Only an explicit optOut()/optIn() overwrites it, so re-assert JS consent every setup. await OptionalReactNativePlugin.setOptOut?.(this.optedOut) + // setAppLanguage() calls made before setup were skipped, so native starts from the + // current value. + if (this._appLanguage !== undefined) { + await OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage) + } if (wasSessionReplayEnabled) { // if somehow the SDK is already enabled with a different sessionId, we reset it this._resetSessionId(OptionalReactNativePlugin, String(sessionId)) diff --git a/packages/react-native/test/app-language.spec.ts b/packages/react-native/test/app-language.spec.ts new file mode 100644 index 0000000000..8e7df82fb5 --- /dev/null +++ b/packages/react-native/test/app-language.spec.ts @@ -0,0 +1,162 @@ +import { PostHog, PostHogCustomStorage, PostHogOptions } from '../src' +import { OptionalReactNativePlugin } from '../src/optional/OptionalPlugin' +import { Linking, AppState } from 'react-native' +import { waitForExpect, wait } from './test-utils' + +vi.mock('../src/optional/OptionalPlugin', () => ({ + OptionalReactNativePluginVersion: '3.0.0', + OptionalReactNativePlugin: { + setup: vi.fn(async () => {}), + setOptOut: vi.fn(async () => {}), + startSession: vi.fn(async () => {}), + endSession: vi.fn(async () => {}), + isEnabled: vi.fn(async () => false), + identify: vi.fn(async () => {}), + setAppLanguage: vi.fn(async () => {}), + registerPushNotificationToken: vi.fn(async () => {}), + }, +})) + +const native = OptionalReactNativePlugin as unknown as Record<'setup' | 'setAppLanguage', vi.Mock | undefined> + +Linking.getInitialURL = vi.fn(() => Promise.resolve(null)) +AppState.addEventListener = vi.fn(() => ({ remove: () => {} })) as any + +describe('setAppLanguage', () => { + vi.useRealTimers() + + let clients: PostHog[] = [] + let storage: PostHogCustomStorage + const setAppLanguageMock = native.setAppLanguage! + + const newClient = async (apiKey = 'test-token', options?: PostHogOptions): Promise => { + const client = new PostHog(apiKey, { customStorage: storage, flushInterval: 0, ...options }) + clients.push(client) + await client.ready() + return client + } + + const captured = async (client: PostHog, properties?: { [key: string]: any }): Promise => { + const onCapture = vi.fn() + client.on('capture', onCapture) + client.capture('event', properties) + await waitForExpect(200, () => expect(onCapture).toHaveBeenCalled()) + return onCapture.mock.calls[0][0].properties + } + + beforeEach(() => { + native.setAppLanguage = setAppLanguageMock + setAppLanguageMock.mockClear() + native.setup!.mockClear() + ;(globalThis as any).window.fetch = vi.fn(async (url: string) => { + const res = url.includes('flags') ? { featureFlags: {}, sessionRecording: { endpoint: '/s/' } } : { status: 'ok' } + return { status: 200, json: () => Promise.resolve(res) } + }) + const cache: { [key: string]: string } = {} + storage = { + getItem: async (key) => cache[key] || null, + setItem: async (key, value) => { + cache[key] = value + }, + } + }) + + afterEach(async () => { + await Promise.all(clients.map((c) => c.shutdown())) + clients = [] + }) + + it('sends no $app_language by default', async () => { + const client = await newClient() + + expect(await captured(client)).not.toHaveProperty('$app_language') + }) + + it.each([ + { input: 'de', expected: 'de' }, + { input: ' pt_BR ', expected: 'pt-BR' }, + { input: 'zh_Hant_TW', expected: 'zh-Hant-TW' }, + ])('stamps $input as $expected', async ({ input, expected }) => { + const client = await newClient() + + client.setAppLanguage(input) + + expect((await captured(client)).$app_language).toBe(expected) + }) + + it.each([null, '', ' ', 42 as unknown as string])('clears it when given %p', async (input) => { + const client = await newClient() + client.setAppLanguage('de') + + client.setAppLanguage(input) + + expect(await captured(client)).not.toHaveProperty('$app_language') + }) + + it('survives reset()', async () => { + const client = await newClient() + client.setAppLanguage('de') + + client.reset() + + expect((await captured(client)).$app_language).toBe('de') + }) + + it('loses to a registered or captured $app_language', async () => { + const client = await newClient() + client.setAppLanguage('de') + await client.register({ $app_language: 'fr' }) + + expect((await captured(client)).$app_language).toBe('fr') + expect((await captured(client, { $app_language: 'es' })).$app_language).toBe('es') + }) + + it('forwards a language set before native setup once setup runs', async () => { + const client = await newClient('test-token', { + persistence: 'memory', + capturePushNotificationSubscriptions: false, + capturePushNotificationOpened: false, + }) + client.setAppLanguage('de') + await wait(50) + expect(setAppLanguageMock).not.toHaveBeenCalled() + + await client.registerPushNotificationToken('device-token') + + expect(native.setup).toHaveBeenCalled() + expect(setAppLanguageMock).toHaveBeenCalledWith('de') + }) + + it('forwards changes and clears to native after setup', async () => { + const client = await newClient('test-token', { enableSessionReplay: true }) + await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) + + client.setAppLanguage('pt_BR') + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith('pt-BR')) + client.setAppLanguage(null) + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith(null)) + }) + + it("keeps another project's language off the native SDK", async () => { + await newClient('test-token', { enableSessionReplay: true }) + await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) + const other = await newClient('other-project-token', { enableSessionReplay: true }) + await wait(50) + + other.setAppLanguage('de') + await wait(50) + + expect(setAppLanguageMock).not.toHaveBeenCalled() + expect((await captured(other)).$app_language).toBe('de') + }) + + it('still stamps JS events with a plugin that predates setAppLanguage', async () => { + native.setAppLanguage = undefined + const client = await newClient('test-token', { enableSessionReplay: true }) + await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) + + client.setAppLanguage('de') + + expect((await captured(client)).$app_language).toBe('de') + }) +}) From b20440def50eb55a0baa80dd2b8137aa4b26fb9f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 15:11:32 -0500 Subject: [PATCH 2/6] docs(react-native): show how to feed setAppLanguage from i18next --- .../posthog-react-native-references-latest.json | 6 +++--- packages/react-native/src/posthog-rn.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index b1c17dcbbf..65ae4beca8 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -1141,9 +1141,9 @@ "title": "setAppLanguage", "examples": [ { - "id": "", - "name": "", - "code": "\n\nposthog.setAppLanguage('de')\n// after the user switches language\nposthog.setAppLanguage(i18n.language)\n\n\n\n" + "id": "with_i18next_or_react-i18next", + "name": "With i18next or react-i18next", + "code": "\n\n// With i18next or react-i18next\nposthog.setAppLanguage(i18n.resolvedLanguage)\ni18n.on('languageChanged', (language) => posthog.setAppLanguage(language))\n\n// Your own language setting\nposthog.setAppLanguage(settings.language) // at startup\nposthog.setAppLanguage('de') // when the user picks German\n\n\n\n" } ], "releaseTag": "public", diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 2ad27418bd..0d73100da7 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -1100,9 +1100,13 @@ export class PostHog extends PostHogCore { * * @example * ```js - * posthog.setAppLanguage('de') - * // after the user switches language - * posthog.setAppLanguage(i18n.language) + * // With i18next or react-i18next + * posthog.setAppLanguage(i18n.resolvedLanguage) + * i18n.on('languageChanged', (language) => posthog.setAppLanguage(language)) + * + * // Your own language setting + * posthog.setAppLanguage(settings.language) // at startup + * posthog.setAppLanguage('de') // when the user picks German * ``` * * @public From 46757d1e95b6821d7170c52db1c91a058d046000 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 15:40:16 -0500 Subject: [PATCH 3/6] fix(react-native): let a session-registered $app_language win over setAppLanguage() --- packages/react-native/src/posthog-rn.ts | 5 +++-- packages/react-native/test/app-language.spec.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 0d73100da7..03f367d355 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -868,9 +868,10 @@ export class PostHog extends PostHogCore { ...super.getCommonEventProperties(), ...this._appProperties, // A registered $app_language wins, as on the native SDKs. Common properties override - // registered ones, so skip ours rather than overwrite it. + // registered and session ones, so skip ours rather than overwrite it. ...(this._appLanguage !== undefined && - this.props.$app_language === undefined && { $app_language: this._appLanguage }), + this.props.$app_language === undefined && + this.sessionProps.$app_language === undefined && { $app_language: this._appLanguage }), $screen_height: height, $screen_width: width, } diff --git a/packages/react-native/test/app-language.spec.ts b/packages/react-native/test/app-language.spec.ts index 8e7df82fb5..5f18a13405 100644 --- a/packages/react-native/test/app-language.spec.ts +++ b/packages/react-native/test/app-language.spec.ts @@ -111,6 +111,14 @@ describe('setAppLanguage', () => { expect((await captured(client, { $app_language: 'es' })).$app_language).toBe('es') }) + it('loses to a session-registered $app_language', async () => { + const client = await newClient() + client.setAppLanguage('de') + client.registerForSession({ $app_language: 'fr' }) + + expect((await captured(client)).$app_language).toBe('fr') + }) + it('forwards a language set before native setup once setup runs', async () => { const client = await newClient('test-token', { persistence: 'memory', From f66647231b8f3d2b1528a9eb5f240bf2d117c9d3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 15:40:42 -0500 Subject: [PATCH 4/6] fix(react-native): clear a stale native $app_language when setup runs without one --- packages/react-native/src/posthog-rn.ts | 8 +++----- packages/react-native/test/app-language.spec.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 03f367d355..2da5dfb201 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -3433,11 +3433,9 @@ export class PostHog extends PostHogCore { // earlier optIn() keeps winning and sdkOptions.optOut alone can't opt a user back out. // Only an explicit optOut()/optIn() overwrites it, so re-assert JS consent every setup. await OptionalReactNativePlugin.setOptOut?.(this.optedOut) - // setAppLanguage() calls made before setup were skipped, so native starts from the - // current value. - if (this._appLanguage !== undefined) { - await OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage) - } + // setAppLanguage() calls made before setup were skipped, and the native SDK is shared by + // the process, so sync it to this client's value, clearing one an earlier client left. + await OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage ?? null) if (wasSessionReplayEnabled) { // if somehow the SDK is already enabled with a different sessionId, we reset it this._resetSessionId(OptionalReactNativePlugin, String(sessionId)) diff --git a/packages/react-native/test/app-language.spec.ts b/packages/react-native/test/app-language.spec.ts index 5f18a13405..ae113bb643 100644 --- a/packages/react-native/test/app-language.spec.ts +++ b/packages/react-native/test/app-language.spec.ts @@ -135,6 +135,12 @@ describe('setAppLanguage', () => { expect(setAppLanguageMock).toHaveBeenCalledWith('de') }) + it('clears the native language on setup when none is set', async () => { + await newClient('test-token', { enableSessionReplay: true }) + + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) + }) + it('forwards changes and clears to native after setup', async () => { const client = await newClient('test-token', { enableSessionReplay: true }) await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) @@ -147,7 +153,8 @@ describe('setAppLanguage', () => { it("keeps another project's language off the native SDK", async () => { await newClient('test-token', { enableSessionReplay: true }) - await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) + setAppLanguageMock.mockClear() const other = await newClient('other-project-token', { enableSessionReplay: true }) await wait(50) From 195bb8203319be0e7c0902621519be27b194e965 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 15:41:30 -0500 Subject: [PATCH 5/6] fix(react-native): bound the post-setup setAppLanguage call with the native timeout --- packages/react-native/src/posthog-rn.ts | 9 ++++++++- packages/react-native/test/app-language.spec.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 2da5dfb201..40091746b6 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -3435,7 +3435,14 @@ export class PostHog extends PostHogCore { await OptionalReactNativePlugin.setOptOut?.(this.optedOut) // setAppLanguage() calls made before setup were skipped, and the native SDK is shared by // the process, so sync it to this client's value, clearing one an earlier client left. - await OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage ?? null) + // Bounded and isolated like _enqueueNative: a hung or failing call must not stall setup + // or the commands queued behind it. + await raceWithTimeout( + Promise.resolve(OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage ?? null)), + NATIVE_CALL_TIMEOUT_MS, + () => + this._logger.warn(`Native PostHog setAppLanguage timed out after ${NATIVE_CALL_TIMEOUT_MS}ms; continuing.`) + ).catch((e) => this._logger.warn(`Native PostHog setAppLanguage failed: ${e}.`)) if (wasSessionReplayEnabled) { // if somehow the SDK is already enabled with a different sessionId, we reset it this._resetSessionId(OptionalReactNativePlugin, String(sessionId)) diff --git a/packages/react-native/test/app-language.spec.ts b/packages/react-native/test/app-language.spec.ts index ae113bb643..163a333566 100644 --- a/packages/react-native/test/app-language.spec.ts +++ b/packages/react-native/test/app-language.spec.ts @@ -141,6 +141,17 @@ describe('setAppLanguage', () => { await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) }) + it('finishes native setup when setAppLanguage fails', async () => { + setAppLanguageMock.mockRejectedValueOnce(new Error('bridge gone')) + const client = await newClient('test-token', { enableSessionReplay: true }) + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) + + client.setAppLanguage('de') + + await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith('de')) + expect(native.setup).toHaveBeenCalledTimes(1) + }) + it('forwards changes and clears to native after setup', async () => { const client = await newClient('test-token', { enableSessionReplay: true }) await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) From f766647f0734d6abaa06286384dcebfda0d70709 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 6 Nov 2026 16:21:49 -0500 Subject: [PATCH 6/6] fix(react-native): pass the app language in native setup options so cold-start push opens carry it --- ...osthog-react-native-references-latest.json | 2 +- packages/react-native/src/posthog-rn.ts | 16 +++++---------- ...ession-replay-request-headers.spec.ts.snap | 2 ++ .../react-native/test/app-language.spec.ts | 20 ++++--------------- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index 65ae4beca8..5427b78f5a 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -1134,7 +1134,7 @@ }, { "category": "Capture", - "description": "Sets the `$app_language` property: the language your app displays, sent with every event.\nDefault: not set, so events carry no `$app_language`. React Native apps usually translate in JavaScript (for example with i18next), which the SDK can't see, so call this at startup and whenever the language changes. Compare `$locale`, which is the device language.\nThe value is kept in memory only and survives `reset()`. A `$app_language` you `register()` wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK captures itself, such as crashes and push opens, carry it too.", + "description": "Sets the `$app_language` property: the language your app displays, sent with every event.\nDefault: not set, so events carry no `$app_language`. React Native apps usually translate in JavaScript (for example with i18next), which the SDK can't see, so call this at startup and whenever the language changes. Compare `$locale`, which is the device language.\nThe value is kept in memory only and survives `reset()`. A `$app_language` you `register()` wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK captures itself, such as crashes and push opens, carry it too. Call it right after creating the client so a push open that launched the app carries it as well.", "details": null, "id": "setAppLanguage", "showDocs": true, diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 40091746b6..51f52f901f 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -1095,7 +1095,8 @@ export class PostHog extends PostHogCore { * * The value is kept in memory only and survives `reset()`. A `$app_language` you `register()` * wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK - * captures itself, such as crashes and push opens, carry it too. + * captures itself, such as crashes and push opens, carry it too. Call it right after creating + * the client so a push open that launched the app carries it as well. * * {@label Capture} * @@ -3368,6 +3369,9 @@ export class PostHog extends PostHogCore { // Native-sent requests (session replay, crash uploads) bypass the JS request path, // so the configured headers are passed through to the native plugin as well. requestHeaders: this._requestHeaders, + // Applied before native setup, so the push open setup captures on a cold launch carries it. + // null clears a value an earlier client left on the process-wide native SDK. + appLanguage: this._appLanguage ?? null, } // Log header names only: requestHeaders values can carry secrets (e.g. an Authorization token). @@ -3433,16 +3437,6 @@ export class PostHog extends PostHogCore { // earlier optIn() keeps winning and sdkOptions.optOut alone can't opt a user back out. // Only an explicit optOut()/optIn() overwrites it, so re-assert JS consent every setup. await OptionalReactNativePlugin.setOptOut?.(this.optedOut) - // setAppLanguage() calls made before setup were skipped, and the native SDK is shared by - // the process, so sync it to this client's value, clearing one an earlier client left. - // Bounded and isolated like _enqueueNative: a hung or failing call must not stall setup - // or the commands queued behind it. - await raceWithTimeout( - Promise.resolve(OptionalReactNativePlugin.setAppLanguage?.(this._appLanguage ?? null)), - NATIVE_CALL_TIMEOUT_MS, - () => - this._logger.warn(`Native PostHog setAppLanguage timed out after ${NATIVE_CALL_TIMEOUT_MS}ms; continuing.`) - ).catch((e) => this._logger.warn(`Native PostHog setAppLanguage failed: ${e}.`)) if (wasSessionReplayEnabled) { // if somehow the SDK is already enabled with a different sessionId, we reset it this._resetSessionId(OptionalReactNativePlugin, String(sessionId)) diff --git a/packages/react-native/test/__snapshots__/session-replay-request-headers.spec.ts.snap b/packages/react-native/test/__snapshots__/session-replay-request-headers.spec.ts.snap index 82da5c2e70..f388cd4437 100644 --- a/packages/react-native/test/__snapshots__/session-replay-request-headers.spec.ts.snap +++ b/packages/react-native/test/__snapshots__/session-replay-request-headers.spec.ts.snap @@ -20,6 +20,7 @@ exports[`PostHog RN session replay request headers > snapshots the complete lega "sdkOptions": { "anonymousId": "", "apiKey": "test-token", + "appLanguage": null, "debug": false, "distinctId": "", "flushAt": 20, @@ -74,6 +75,7 @@ exports[`PostHog RN session replay request headers > snapshots the complete nati "sdkOptions": { "anonymousId": "replay-snapshot-id", "apiKey": "test-token", + "appLanguage": null, "debug": false, "distinctId": "replay-snapshot-id", "flushAt": 20, diff --git a/packages/react-native/test/app-language.spec.ts b/packages/react-native/test/app-language.spec.ts index 163a333566..fbf968c4d2 100644 --- a/packages/react-native/test/app-language.spec.ts +++ b/packages/react-native/test/app-language.spec.ts @@ -131,25 +131,14 @@ describe('setAppLanguage', () => { await client.registerPushNotificationToken('device-token') - expect(native.setup).toHaveBeenCalled() - expect(setAppLanguageMock).toHaveBeenCalledWith('de') + expect(native.setup!.mock.calls[0][1]).toMatchObject({ appLanguage: 'de' }) }) it('clears the native language on setup when none is set', async () => { await newClient('test-token', { enableSessionReplay: true }) - await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) - }) - - it('finishes native setup when setAppLanguage fails', async () => { - setAppLanguageMock.mockRejectedValueOnce(new Error('bridge gone')) - const client = await newClient('test-token', { enableSessionReplay: true }) - await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) - - client.setAppLanguage('de') - - await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith('de')) - expect(native.setup).toHaveBeenCalledTimes(1) + await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) + expect(native.setup!.mock.calls[0][1]).toMatchObject({ appLanguage: null }) }) it('forwards changes and clears to native after setup', async () => { @@ -164,8 +153,7 @@ describe('setAppLanguage', () => { it("keeps another project's language off the native SDK", async () => { await newClient('test-token', { enableSessionReplay: true }) - await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenCalledWith(null)) - setAppLanguageMock.mockClear() + await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled()) const other = await newClient('other-project-token', { enableSessionReplay: true }) await wait(50)