Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/runtime-locale-identifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"expo-superwall": minor
---

Expose `setLocaleIdentifier(string | null)` in the hooks and compat APIs to change the native locale override after configuration, or restore the device locale with `null`.
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,10 @@ class SuperwallExpoModule : Module() {
}
}

Function("setLocaleIdentifier") { localeIdentifier: String? ->
Superwall.instance.localeIdentifier = localeIdentifier
}

AsyncFunction("setIntegrationAttributes") { attributes: Map<String, String>, promise: Promise ->
scope.launch {
try {
Expand Down
4 changes: 4 additions & 0 deletions ios/SuperwallExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ public class SuperwallExpoModule: Module {
}
}

Function("setLocaleIdentifier") { (localeIdentifier: String?) in
Superwall.shared.localeIdentifier = localeIdentifier
}

AsyncFunction("setIntegrationAttributes") { (attributes: [String: String], promise: Promise) in
var converted: [IntegrationAttribute: String] = [:]

Expand Down
1 change: 1 addition & 0 deletions src/SuperwallExpoModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ declare class SuperwallExpoModule extends NativeModule<SuperwallExpoModuleEvents
preloadAllPaywalls(): void

setLogLevel(level: string): void
setLocaleIdentifier(localeIdentifier: string | null): void
setEventTrackingBehavior(behavior: string): void

setIntegrationAttributes(attributes: IntegrationAttributes): Promise<void>
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/compat.locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const mockConfigure = jest.fn().mockResolvedValue(undefined)
const mockSetLocaleIdentifier = jest.fn()

// The compat configuration gate only needs an in-process event emitter.
jest.mock("expo", () => ({ EventEmitter: require("node:events").EventEmitter }))

jest.mock("../SuperwallExpoModule", () => ({
__esModule: true,
default: {
addListener: jest.fn(() => ({ remove: jest.fn() })),
configure: mockConfigure,
setLocaleIdentifier: mockSetLocaleIdentifier,
},
}))

const { default: Superwall }: typeof import("../compat") = require("../compat")

describe("compat runtime locale", () => {
it("waits for configure, changes the locale, and resets without reconfiguring", async () => {
let resolveConfigure: (() => void) | undefined
mockConfigure.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveConfigure = resolve
}),
)

const pendingConfigure = Superwall.configure({ apiKey: "api-key" })
const pendingLocale = Superwall.shared.setLocaleIdentifier("es_ES")
await Promise.resolve()
expect(mockSetLocaleIdentifier).not.toHaveBeenCalled()

resolveConfigure?.()
await pendingConfigure
await pendingLocale
expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES")

await Superwall.shared.setLocaleIdentifier(null)
expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]])
expect(mockConfigure).toHaveBeenCalledTimes(1)
})
})
26 changes: 26 additions & 0 deletions src/__tests__/sdk.behavior.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mockGetCustomerInfo = jest.fn().mockResolvedValue({
const mockSetSubscriptionStatus = jest.fn().mockResolvedValue(undefined)
const mockSetIntegrationAttributes = jest.fn().mockResolvedValue(undefined)
const mockTogglePaywallSpinner = jest.fn()
const mockSetLocaleIdentifier = jest.fn()
const mockAddListener = jest.fn(
(eventName: string, listener: (payload: any) => void): { remove: () => void } => {
const listeners = mockListeners.get(eventName) ?? new Set()
Expand Down Expand Up @@ -65,6 +66,7 @@ jest.mock("../SuperwallExpoModule", () => ({
didHandleBackPressed: mockDidHandleBackPressed,
didHandleCustomCallback: mockDidHandleCustomCallback,
togglePaywallSpinner: mockTogglePaywallSpinner,
setLocaleIdentifier: mockSetLocaleIdentifier,
},
}))

Expand Down Expand Up @@ -321,6 +323,30 @@ describe("SDK behavior regressions", () => {
expect(mockSetSubscriptionStatus).toHaveBeenCalledWith({ status: "INACTIVE" })
})

it("waits for configure before changing the locale and can reset to the device locale", async () => {
let resolveConfigure: ((value: boolean) => void) | undefined
mockConfigure.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveConfigure = resolve
}),
)

const pendingLocale = useSuperwallStore.getState().setLocaleIdentifier("es_ES")
const pendingConfigure = useSuperwallStore.getState().configure("api-key")
await Promise.resolve()

expect(mockSetLocaleIdentifier).not.toHaveBeenCalled()

resolveConfigure?.(true)
await pendingConfigure
await pendingLocale
expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES")

await useSuperwallStore.getState().setLocaleIdentifier(null)
expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]])
expect(mockConfigure).toHaveBeenCalledTimes(1)
})

it("waits for configure before setting integration attributes", async () => {
let resolveConfigure: ((value: boolean) => void) | undefined
mockConfigure.mockReturnValueOnce(
Expand Down
12 changes: 12 additions & 0 deletions src/compat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,18 @@ export default class Superwall {
await SuperwallExpoModule.setEventTrackingBehavior(behavior.toString())
}

/**
* Sets the locale identifier for the Superwall SDK.
* This determines the language used when presenting paywalls.
* Can be changed at runtime without needing to reconfigure.
*
* @param localeIdentifier - The locale identifier (e.g., "en_US", "es_ES"), or `null` to reset to the device locale.
*/
async setLocaleIdentifier(localeIdentifier: string | null): Promise<void> {
await this.awaitConfig()
await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier)
}

/**
* Sets attributes for third-party integrations.
* @param attributes - Object mapping IntegrationAttribute string values to their IDs
Expand Down
16 changes: 15 additions & 1 deletion src/useSuperwall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,15 @@ export interface SuperwallStore {
*/
setLogLevel: (level: string) => Promise<void>

/**
* Sets the locale identifier for the Superwall SDK.
* This determines the language used when presenting paywalls.
* Can be changed at runtime without needing to reconfigure.
* @param localeIdentifier - The locale identifier (e.g., "en", "es", "fr"), or `null` to reset to the device locale.
* @returns A promise that resolves when the locale identifier is set.
*/
setLocaleIdentifier: (localeIdentifier: string | null) => Promise<void>

/**
* Sets which events Superwall tracks at runtime, for GDPR/data-collection control.
* @param behavior - The desired event tracking behavior ("all", "superwallOnly", or "none").
Expand Down Expand Up @@ -438,7 +447,7 @@ export const useSuperwallStore = create<SuperwallStore>((set, get) => ({
identify: async (userId, options) => {
await awaitConfigured()

// The previous identity's purchases must not leak into the new one.
// The previous identity's purchases must not leak into the new one.
set({ customerInfo: null })

await SuperwallExpoModule.identify(userId, options)
Expand Down Expand Up @@ -526,6 +535,11 @@ export const useSuperwallStore = create<SuperwallStore>((set, get) => ({
await SuperwallExpoModule.setEventTrackingBehavior(behavior)
},

setLocaleIdentifier: async (localeIdentifier) => {
await awaitConfigured()
await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier)
},

setIntegrationAttributes: async (attributes) => {
await awaitConfigured()
await SuperwallExpoModule.setIntegrationAttributes(attributes)
Expand Down
Loading