@oxy.so/services 13.5.0 → 13.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/lib/commonjs/ui/components/AccountLanguageBoundary.js +35 -0
  2. package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
  3. package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
  4. package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyProvider.js +19 -26
  6. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  7. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
  8. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  9. package/lib/commonjs/ui/context/OxyContext.js +19 -2
  10. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
  12. package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
  13. package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
  14. package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
  15. package/lib/commonjs/ui/screens/AccountMembersScreen.js +8 -1
  16. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  17. package/lib/commonjs/ui/utils/entryLink.js +54 -0
  18. package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
  19. package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
  20. package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
  21. package/lib/module/ui/components/LanguageBridge.js +38 -35
  22. package/lib/module/ui/components/LanguageBridge.js.map +1 -1
  23. package/lib/module/ui/components/OxyProvider.js +18 -27
  24. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  25. package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
  26. package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  27. package/lib/module/ui/context/OxyContext.js +19 -2
  28. package/lib/module/ui/context/OxyContext.js.map +1 -1
  29. package/lib/module/ui/hooks/useEntryLink.js +94 -0
  30. package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
  31. package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
  32. package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
  33. package/lib/module/ui/screens/AccountMembersScreen.js +8 -1
  34. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  35. package/lib/module/ui/utils/entryLink.js +49 -0
  36. package/lib/module/ui/utils/entryLink.js.map +1 -0
  37. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
  38. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
  40. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
  45. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
  47. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
  49. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
  52. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
  54. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
  55. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
  56. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  57. package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
  58. package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
  63. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  64. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
  65. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
  66. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
  67. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  68. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
  70. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
  72. package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
  73. package/package.json +5 -5
  74. package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
  75. package/src/ui/components/LanguageBridge.tsx +43 -39
  76. package/src/ui/components/OxyProvider.tsx +18 -24
  77. package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
  78. package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
  79. package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
  80. package/src/ui/context/OxyContext.tsx +21 -1
  81. package/src/ui/context/oxyContextTypes.ts +5 -0
  82. package/src/ui/hooks/useEntryLink.ts +103 -0
  83. package/src/ui/hooks/useLanguageManagement.ts +53 -18
  84. package/src/ui/screens/AccountMembersScreen.tsx +8 -1
  85. package/src/ui/types/navigation.ts +4 -0
  86. package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
  87. package/src/ui/utils/entryLink.ts +61 -0
@@ -0,0 +1,8 @@
1
+ import { type CampaignAttribution } from '@oxy.so/contracts/client';
2
+ export declare function readCampaignAttribution(value: unknown): CampaignAttribution | undefined;
3
+ /** Consumes only explicit locale/campaign parameters, never the URL or referrer itself. */
4
+ export declare function readEntryLink(url: string | null): {
5
+ language?: string;
6
+ campaignAttribution?: CampaignAttribution;
7
+ };
8
+ //# sourceMappingURL=entryLink.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entryLink.d.ts","sourceRoot":"","sources":["../../../../../src/ui/utils/entryLink.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,0BAA0B,CAAC;AAWlC,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,GAAG,SAAS,CAavF;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C,CAuBA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "13.5.0",
3
+ "version": "13.7.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -151,7 +151,7 @@
151
151
  "delete-dts.js": "find ./lib/commonjs -name '*.d.js*' -delete && find ./lib/module -name '*.d.js*' -delete"
152
152
  },
153
153
  "dependencies": {
154
- "@oxy.so/contracts": "^6.0.0",
154
+ "@oxy.so/contracts": "^6.1.0",
155
155
  "color": "^5.0.3",
156
156
  "@peable.to/sdk": "0.4.1"
157
157
  },
@@ -160,7 +160,7 @@
160
160
  "@commitlint/config-conventional": "^21.2.0",
161
161
  "@expo/app-integrity": "~58.0.2",
162
162
  "@expo/vector-icons": "^15.0.3",
163
- "@oxy.so/core": "7.2.0",
163
+ "@oxy.so/core": "7.6.0",
164
164
  "@react-native-async-storage/async-storage": "^2.0.0",
165
165
  "@react-native-community/netinfo": "^12.0.1",
166
166
  "@react-native/babel-preset": "^0.88.0",
@@ -210,13 +210,13 @@
210
210
  "expo-video": "~58.0.12"
211
211
  },
212
212
  "//@oxy.so/bloom": "Bloom 11.1.0 adds the shared grouped sections, usage card, subscription plans, frame appearance and overlay navigation APIs used by this SDK. First-party Bloom peers remain open above this tested minimum.",
213
- "//@oxy.so/core": "Core 7.2.0 supplies `assets.cachedUrl`, with which `useFileDownloadUrl` answers an already-resolved URL in its first render; 6.16.0 supplies `requireServerVerdict` and `onHolderRejected`, with which the cold boot checks a session it painted from the cache; 6.13.0 supplies the `accounts.settings.*` strings (picture, categories) the account settings speak in every language; 6.12.0 supplies `fileManagement.contentLost` and the `lost` file status a lost file is listed with; 6.11.0 supplies the `follow.errors.*` strings the follow error codes are worded with; 6.4.0 the `follow.*` strings the follow buttons read for every verb and language; 6.1.0 the `oxy.auth.email.{startChange,change,remove}` and `oxy.auth.password.remove` the sign-in & security panels call. Keep one Core instance per app; raise this floor with the root catalogue when new runtime APIs are required.",
213
+ "//@oxy.so/core": "Core 7.6.0 forwards guest locale and first-touch campaign metadata through signup; 7.3.0 supplies `users.lookupMany`, with which the account members screen fails (and retries) a profile read whose chunk failed instead of caching those members as handle-less; 7.2.0 supplies `assets.cachedUrl`, with which `useFileDownloadUrl` answers an already-resolved URL in its first render; 6.16.0 supplies `requireServerVerdict` and `onHolderRejected`, with which the cold boot checks a session it painted from the cache; 6.13.0 supplies the `accounts.settings.*` strings (picture, categories) the account settings speak in every language; 6.12.0 supplies `fileManagement.contentLost` and the `lost` file status a lost file is listed with; 6.11.0 supplies the `follow.errors.*` strings the follow error codes are worded with; 6.4.0 the `follow.*` strings the follow buttons read for every verb and language; 6.1.0 the `oxy.auth.email.{startChange,change,remove}` and `oxy.auth.password.remove` the sign-in & security panels call. Keep one Core instance per app; raise this floor with the root catalogue when new runtime APIs are required.",
214
214
  "//react-native": "React Native 0.81 to 0.88, React 19.1+ and Expo SDK 54 to 58. The floors are the Expo SDK 54 stack (react-native 0.81.6, react 19.1.4, react-native-web 0.21.4, expo 54.0.38 and the module versions its bundledNativeModules.json pins), which compat/expo-54 installs as an external consumer and checks on every CI run (`bun check.mjs`): every peer here admits that stack, services' published src typechecks against its types, and <OxyProvider> mounts and settles its cold boot on iOS (React Native 0.81), web (react-native-web 0.21) and react-native-macos 0.81 with the native modules macOS lacks absent. Astro, Oxy's browser, runs react-native-macos 0.81 (0.83 is its latest release), which is why the floor is not 0.85. The SDK 56 floors these replaced were the versions current when the ranges were first written, not measurements.",
215
215
  "peerDependencies": {
216
216
  "@expo/app-integrity": ">=0.1.10 <59.0.0",
217
217
  "@expo/vector-icons": "^15.0.3",
218
218
  "@oxy.so/bloom": ">=11.1.0",
219
- "@oxy.so/core": "^7.2.0",
219
+ "@oxy.so/core": "^7.6.0",
220
220
  "@react-native-async-storage/async-storage": "^2.0.0",
221
221
  "@react-native-community/netinfo": ">=11.4.1 <13.0.0",
222
222
  "@tanstack/query-async-storage-persister": "^5.101",
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react';
2
+ import { FALLBACK_LOCALE, getPrimaryLanguage } from '@oxy.so/core';
3
+ import { useOxy } from '../context/oxyRuntimeContext';
4
+ import { useCurrentUser } from '../hooks/queries/useAccountQueries';
5
+ import type { OxyLanguageConfig } from '../types/navigation';
6
+ import { LanguageBridge } from './LanguageBridge';
7
+
8
+ /** Owner preferences are not part of the public device/account summary. */
9
+ export function AccountLanguageBoundary({
10
+ language,
11
+ children,
12
+ }: {
13
+ language?: OxyLanguageConfig;
14
+ children: ReactNode;
15
+ }) {
16
+ const { canUsePrivateApi, user, currentLanguage } = useOxy();
17
+ const profile = useCurrentUser({ enabled: canUsePrivateApi });
18
+ // The query mirrors its result into the account runtime in an effect. Wait for
19
+ // that projection as well as the request, so the bridge never publishes the
20
+ // compact session's fallback locale in the frame before its real preference.
21
+ const accountReady =
22
+ !user ||
23
+ !canUsePrivateApi ||
24
+ profile.fetchStatus === 'paused' ||
25
+ user.languages !== undefined ||
26
+ profile.isError ||
27
+ (profile.dataUpdatedAt > 0 &&
28
+ profile.data?.id === user.id &&
29
+ currentLanguage === (getPrimaryLanguage(profile.data) ?? FALLBACK_LOCALE));
30
+ return (
31
+ <LanguageBridge config={language} accountReady={accountReady}>
32
+ {children}
33
+ </LanguageBridge>
34
+ );
35
+ }
@@ -1,49 +1,53 @@
1
- import { useEffect, useMemo, useRef } from 'react';
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { coerceToSupportedLocale } from '@oxy.so/core';
3
3
  import { useOxy } from '../context/oxyRuntimeContext';
4
4
  import type { OxyLanguageConfig } from '../types/navigation';
5
5
 
6
- /**
7
- * `OxyProvider`'s language config prop, realized. Oxy decides which
8
- * language an account (or, signed out, the device/guest locale) should see;
9
- * this is the ONE place that decision reaches the host app's own i18n library
10
- * — so an app never re-derives or imperatively tracks a language of its own,
11
- * and never mounts anything extra to get it. It owns only its translation
12
- * catalogs.
13
- *
14
- * `currentLanguage` may name a locale the host app never shipped a catalog
15
- * for — {@link coerceToSupportedLocale} narrows it to the closest one the app
16
- * actually has (exact match, then same base language, then `fallbackLocale`).
17
- *
18
- * Renders nothing. Mounted by `OxyProvider` INSIDE `OxyRuntimeProvider` only
19
- * when this config is supplied, so an app with no i18n of its own pays
20
- * nothing.
21
- */
6
+ /** The SDK resolves the entry/account locale before exposing translated app content. */
22
7
  export function LanguageBridge({
23
- supportedLocales,
24
- fallbackLocale,
25
- onChange,
26
- onError,
27
- }: OxyLanguageConfig): null {
28
- const { currentLanguage } = useOxy();
29
- const resolvedLocale = useMemo(
30
- () => coerceToSupportedLocale(currentLanguage, supportedLocales, fallbackLocale),
31
- [currentLanguage, supportedLocales, fallbackLocale],
32
- );
33
-
34
- // Refs, not effect dependencies: an inline `onChange`/`onError` (the common
35
- // case) must not re-fire the effect on every render — only a genuine
36
- // change in the RESOLVED locale should call it again.
37
- const onChangeRef = useRef(onChange);
38
- onChangeRef.current = onChange;
39
- const onErrorRef = useRef(onError);
40
- onErrorRef.current = onError;
8
+ config,
9
+ accountReady = true,
10
+ children,
11
+ }: {
12
+ config?: OxyLanguageConfig;
13
+ accountReady?: boolean;
14
+ children: ReactNode;
15
+ }) {
16
+ const { currentLanguage, isLanguageReady, isAuthResolved } = useOxy();
17
+ const resolvedLocale = config
18
+ ? coerceToSupportedLocale(currentLanguage, config.supportedLocales, config.fallbackLocale)
19
+ : currentLanguage;
20
+ const configRef = useRef(config);
21
+ configRef.current = config;
22
+ const pendingChange = useRef(Promise.resolve());
23
+ const [prepared, setPrepared] = useState(false);
41
24
 
42
25
  useEffect(() => {
43
- Promise.resolve(onChangeRef.current(resolvedLocale)).catch((error: unknown) => {
44
- onErrorRef.current?.(error, resolvedLocale);
26
+ if (!isLanguageReady || !isAuthResolved || !accountReady) return;
27
+ let active = true;
28
+ // A slow earlier catalog must finish before a later locale is committed.
29
+ // Children stay mounted after boot: changing language never destroys route state.
30
+ pendingChange.current = pendingChange.current.then(async () => {
31
+ if (!active) return;
32
+ const current = configRef.current;
33
+ try {
34
+ await current?.onChange(resolvedLocale);
35
+ } catch (error) {
36
+ current?.onError?.(error, resolvedLocale);
37
+ if (current && resolvedLocale !== current.fallbackLocale) {
38
+ try {
39
+ await current.onChange(current.fallbackLocale);
40
+ } catch (fallbackError) {
41
+ current.onError?.(fallbackError, current.fallbackLocale);
42
+ }
43
+ }
44
+ }
45
+ if (active) setPrepared(true);
45
46
  });
46
- }, [resolvedLocale]);
47
+ return () => {
48
+ active = false;
49
+ };
50
+ }, [accountReady, isAuthResolved, isLanguageReady, resolvedLocale]);
47
51
 
48
- return null;
52
+ return !config || prepared ? children : null;
49
53
  }
@@ -20,19 +20,10 @@ import { isNetConnectivityOnline } from '../utils/netConnectivity';
20
20
  import { KeyboardBoundary } from './KeyboardBoundary';
21
21
  import { SafeAreaRoot } from './SafeAreaRoot';
22
22
  import { ProductAnalyticsObserver } from '../analytics/productAnalytics';
23
- import { LanguageBridge } from './LanguageBridge';
23
+ import { AccountLanguageBoundary } from './AccountLanguageBoundary';
24
24
  import { BloomLocaleBridge } from './BloomLocaleBridge';
25
- import { useOxy } from '../context/oxyRuntimeContext';
26
- import { useCurrentUser } from '../hooks/queries/useAccountQueries';
27
25
  import { SupervisionStateObserver } from '../hooks/queries/useSupervision';
28
26
 
29
- /** Hydrate owner preferences after the bearer is ready, including on cold boot. */
30
- function AccountProfileObserver() {
31
- const { canUsePrivateApi } = useOxy();
32
- useCurrentUser({ enabled: canUsePrivateApi });
33
- return null;
34
- }
35
-
36
27
  const bootStyles = StyleSheet.create({
37
28
  providerRoot: {
38
29
  flex: 1,
@@ -114,6 +105,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
114
105
  requireAuth = 'off',
115
106
  backgroundSession = false,
116
107
  language,
108
+ campaignAttribution = false,
117
109
  }) => {
118
110
  const support = useMemo(
119
111
  () => ({ onAskAlia, documentationUrl, aliaAvatar, oxyOneArtwork }),
@@ -279,26 +271,28 @@ const OxyProvider: FC<OxyProviderProps> = ({
279
271
  sessionMode={sessionMode}
280
272
  webAuthMode={webAuthMode}
281
273
  backgroundSession={backgroundSession}
274
+ legacyLanguageKeys={language?.legacyStorageKeys}
275
+ captureCampaignAttribution={campaignAttribution}
282
276
  platformStorage={platformStorage}
283
277
  accountQueries={accountQueries}
284
278
  onAuthStateChange={onAuthStateChange as OxyRuntimeProviderProps['onAuthStateChange']}
285
279
  >
286
- <AccountProfileObserver />
287
280
  <SupervisionStateObserver />
288
281
  {productAnalytics ? <ProductAnalyticsObserver analytics={productAnalytics} /> : null}
289
- {language ? <LanguageBridge {...language} /> : null}
290
- <BloomLocaleBridge>
291
- <OxySupportContext.Provider value={support}>
292
- <SurfaceProvider>
293
- {requireAuth === 'off' ? (
294
- children
295
- ) : (
296
- <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
297
- )}
298
- </SurfaceProvider>
299
- </OxySupportContext.Provider>
300
- <ToastOutlet />
301
- </BloomLocaleBridge>
282
+ <AccountLanguageBoundary language={language}>
283
+ <BloomLocaleBridge>
284
+ <OxySupportContext.Provider value={support}>
285
+ <SurfaceProvider>
286
+ {requireAuth === 'off' ? (
287
+ children
288
+ ) : (
289
+ <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
290
+ )}
291
+ </SurfaceProvider>
292
+ </OxySupportContext.Provider>
293
+ <ToastOutlet />
294
+ </BloomLocaleBridge>
295
+ </AccountLanguageBoundary>
302
296
  </OxyRuntimeProvider>
303
297
  </QueryClientProvider>
304
298
  );
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import { AccountLanguageBoundary } from '../AccountLanguageBoundary';
4
+
5
+ let mockCurrentLanguage = 'en-US';
6
+ let mockCanUsePrivateApi = true;
7
+ const mockUser = { id: 'account', publicKey: '', username: 'ada', name: {} };
8
+ let mockQuery: {
9
+ data?: typeof mockUser & { languages?: string[] };
10
+ dataUpdatedAt: number;
11
+ isError: boolean;
12
+ fetchStatus?: 'fetching' | 'paused' | 'idle';
13
+ };
14
+ jest.mock('../../context/oxyRuntimeContext', () => ({
15
+ useOxy: () => ({
16
+ canUsePrivateApi: mockCanUsePrivateApi,
17
+ user: mockUser,
18
+ currentLanguage: mockCurrentLanguage,
19
+ isLanguageReady: true,
20
+ isAuthResolved: true,
21
+ }),
22
+ }));
23
+ jest.mock('../../hooks/queries/useAccountQueries', () => ({ useCurrentUser: () => mockQuery }));
24
+
25
+ it('waits for the owner preference query and its runtime projection before painting the account app', async () => {
26
+ mockQuery = { data: mockUser, dataUpdatedAt: 0, isError: false };
27
+ const onChange = jest.fn();
28
+ const config = { supportedLocales: ['en-US', 'es-ES'], fallbackLocale: 'en-US', onChange };
29
+ const screen = render(
30
+ <AccountLanguageBoundary language={config}>
31
+ <span>App</span>
32
+ </AccountLanguageBoundary>,
33
+ );
34
+ expect(screen.queryByText('App')).toBeNull();
35
+ mockQuery = { data: { ...mockUser, languages: ['es-ES'] }, dataUpdatedAt: 123, isError: false };
36
+ screen.rerender(
37
+ <AccountLanguageBoundary language={config}>
38
+ <span>App</span>
39
+ </AccountLanguageBoundary>,
40
+ );
41
+ expect(screen.queryByText('App')).toBeNull();
42
+ expect(onChange).not.toHaveBeenCalled();
43
+ mockCurrentLanguage = 'es-ES';
44
+ screen.rerender(
45
+ <AccountLanguageBoundary language={config}>
46
+ <span>App</span>
47
+ </AccountLanguageBoundary>,
48
+ );
49
+ await waitFor(() => expect(screen.getByText('App')).toBeTruthy());
50
+ expect(onChange.mock.calls).toEqual([['es-ES']]);
51
+ });
52
+
53
+ it.each(['paused', 'disabled', 'error'] as const)(
54
+ 'renders the resolved fallback when the account query is %s, then adopts its preference without remounting',
55
+ async (unavailable) => {
56
+ mockCurrentLanguage = 'en-US';
57
+ mockCanUsePrivateApi = unavailable !== 'disabled';
58
+ mockQuery = {
59
+ data: mockUser,
60
+ dataUpdatedAt: 0,
61
+ isError: unavailable === 'error',
62
+ fetchStatus: unavailable === 'paused' ? 'paused' : 'idle',
63
+ };
64
+ const onChange = jest.fn();
65
+ const config = { supportedLocales: ['en-US', 'es-ES'], fallbackLocale: 'en-US', onChange };
66
+ const view = () => (
67
+ <AccountLanguageBoundary language={config}>
68
+ <input aria-label="Draft" defaultValue="Kept" />
69
+ </AccountLanguageBoundary>
70
+ );
71
+ const screen = render(view());
72
+ await waitFor(() => expect(screen.getByLabelText('Draft')).toBeTruthy());
73
+ const draft = screen.getByLabelText('Draft');
74
+ expect(onChange.mock.calls).toEqual([['en-US']]);
75
+ mockCanUsePrivateApi = true;
76
+ mockQuery = {
77
+ data: { ...mockUser, languages: ['es-ES'] },
78
+ dataUpdatedAt: 123,
79
+ isError: false,
80
+ fetchStatus: 'idle',
81
+ };
82
+ mockCurrentLanguage = 'es-ES';
83
+ screen.rerender(view());
84
+ await waitFor(() => expect(onChange.mock.calls).toEqual([['en-US'], ['es-ES']]));
85
+ expect(screen.getByLabelText('Draft')).toBe(draft);
86
+ },
87
+ );
@@ -1,91 +1,111 @@
1
1
  import React from 'react';
2
- import { render } from '@testing-library/react';
2
+ import { act, render, waitFor } from '@testing-library/react';
3
3
  import { LanguageBridge } from '../LanguageBridge';
4
4
 
5
- /**
6
- * `OxyProvider`'s language config prop, realized: the one place Oxy's
7
- * resolved language reaches a host app's own i18n library. `useOxy` is mocked here —
8
- * it is the SDK's own well-tested context — so these tests exercise exactly
9
- * what this component adds: coercion to the caller's catalog, and calling
10
- * back only when the RESOLVED locale actually changes.
11
- */
12
-
13
5
  let mockCurrentLanguage = 'en-US';
6
+ let mockLanguageReady = true;
7
+ let mockAuthResolved = true;
14
8
  jest.mock('../../context/oxyRuntimeContext', () => ({
15
- useOxy: () => ({ currentLanguage: mockCurrentLanguage }),
9
+ useOxy: () => ({
10
+ currentLanguage: mockCurrentLanguage,
11
+ isLanguageReady: mockLanguageReady,
12
+ isAuthResolved: mockAuthResolved,
13
+ }),
16
14
  }));
17
-
18
15
  const HOST_CATALOG = ['en-US', 'es-ES', 'fr-FR'] as const;
19
-
20
16
  beforeEach(() => {
21
17
  mockCurrentLanguage = 'en-US';
18
+ mockLanguageReady = true;
19
+ mockAuthResolved = true;
22
20
  });
23
21
 
24
- it('resolves and reports the account language on mount', () => {
25
- mockCurrentLanguage = 'es-ES';
22
+ it.each([
23
+ ['es-ES', 'es-ES'],
24
+ ['es-MX', 'es-ES'],
25
+ ['ja-JP', 'en-US'],
26
+ ])('resolves %s to the host catalog %s', async (locale, expected) => {
27
+ mockCurrentLanguage = locale;
26
28
  const onChange = jest.fn();
27
29
  render(
28
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
30
+ <LanguageBridge config={{ supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange }}>
31
+ <span>App</span>
32
+ </LanguageBridge>,
29
33
  );
30
-
31
- expect(onChange).toHaveBeenCalledWith('es-ES');
34
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(expected));
32
35
  });
33
36
 
34
- it('coerces a locale the host never shipped a catalog for, by base language', () => {
35
- mockCurrentLanguage = 'es-MX';
36
- const onChange = jest.fn();
37
- render(
38
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
37
+ it('does not paint any app text before saved preferences, account restoration and catalog loading finish', async () => {
38
+ mockLanguageReady = false;
39
+ mockAuthResolved = false;
40
+ let finishCatalog: (() => void) | undefined;
41
+ const onChange = jest.fn(
42
+ () =>
43
+ new Promise<void>((resolve) => {
44
+ finishCatalog = resolve;
45
+ }),
39
46
  );
40
-
41
- expect(onChange).toHaveBeenCalledWith('es-ES');
42
- });
43
-
44
- it('falls back to the caller default when nothing matches, even by base', () => {
45
- mockCurrentLanguage = 'ja-JP';
46
- const onChange = jest.fn();
47
- render(
48
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
49
- );
50
-
51
- expect(onChange).toHaveBeenCalledWith('en-US');
47
+ const config = { supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange };
48
+ const children = <span>Translated app</span>;
49
+ const screen = render(<LanguageBridge config={config}>{children}</LanguageBridge>);
50
+ expect(screen.queryByText('Translated app')).toBeNull();
51
+ expect(onChange).not.toHaveBeenCalled();
52
+ mockLanguageReady = true;
53
+ mockCurrentLanguage = 'es-ES';
54
+ screen.rerender(<LanguageBridge config={config}>{children}</LanguageBridge>);
55
+ expect(onChange).not.toHaveBeenCalled();
56
+ mockAuthResolved = true;
57
+ screen.rerender(<LanguageBridge config={config}>{children}</LanguageBridge>);
58
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('es-ES'));
59
+ expect(screen.queryByText('Translated app')).toBeNull();
60
+ await act(async () => finishCatalog?.());
61
+ expect(screen.getByText('Translated app')).toBeTruthy();
52
62
  });
53
63
 
54
- it('calls back again only when the RESOLVED locale changes', () => {
64
+ it('keeps route state mounted across a language change and ignores callback identity changes', async () => {
65
+ let mounts = 0;
66
+ function Page() {
67
+ React.useEffect(() => {
68
+ mounts += 1;
69
+ }, []);
70
+ return <span>App</span>;
71
+ }
55
72
  const onChange = jest.fn();
56
- const { rerender } = render(
57
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
73
+ const config = { supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange };
74
+ const screen = render(
75
+ <LanguageBridge config={config}>
76
+ <Page />
77
+ </LanguageBridge>,
58
78
  );
59
- expect(onChange).toHaveBeenCalledTimes(1);
60
-
61
- // Same resolved locale, brand-new callback identity (the common case for an
62
- // inline arrow function) — must not re-fire.
63
- rerender(
64
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={() => {}} />,
79
+ await waitFor(() => expect(mounts).toBe(1));
80
+ screen.rerender(
81
+ <LanguageBridge config={{ ...config, onChange: jest.fn() }}>
82
+ <Page />
83
+ </LanguageBridge>,
65
84
  );
66
85
  expect(onChange).toHaveBeenCalledTimes(1);
67
-
68
86
  mockCurrentLanguage = 'fr-FR';
69
- rerender(
70
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
87
+ screen.rerender(
88
+ <LanguageBridge config={config}>
89
+ <Page />
90
+ </LanguageBridge>,
71
91
  );
72
- expect(onChange).toHaveBeenCalledTimes(2);
73
- expect(onChange).toHaveBeenLastCalledWith('fr-FR');
92
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith('fr-FR'));
93
+ expect(mounts).toBe(1);
74
94
  });
75
95
 
76
- it('reports a rejected onChange to onError instead of throwing', async () => {
77
- const failure = new Error('catalog failed to load');
78
- const onChange = jest.fn().mockRejectedValue(failure);
96
+ it('reports a failed catalog and initializes the fallback before showing children', async () => {
97
+ mockCurrentLanguage = 'es-ES';
98
+ const failure = new Error('catalog unavailable');
99
+ const onChange = jest.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined);
79
100
  const onError = jest.fn();
80
- render(
101
+ const screen = render(
81
102
  <LanguageBridge
82
- supportedLocales={HOST_CATALOG}
83
- fallbackLocale="en-US"
84
- onChange={onChange}
85
- onError={onError}
86
- />,
103
+ config={{ supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange, onError }}
104
+ >
105
+ <span>App</span>
106
+ </LanguageBridge>,
87
107
  );
88
-
89
- await new Promise((resolve) => setTimeout(resolve, 0));
90
- expect(onError).toHaveBeenCalledWith(failure, 'en-US');
108
+ await waitFor(() => expect(screen.getByText('App')).toBeTruthy());
109
+ expect(onError).toHaveBeenCalledWith(failure, 'es-ES');
110
+ expect(onChange.mock.calls.map(([locale]) => locale)).toEqual(['es-ES', 'en-US']);
91
111
  });
@@ -67,7 +67,13 @@ export const OxySignUpPanel: React.FC<OxySignUpPanelProps> = ({
67
67
  host = 'page',
68
68
  }) => {
69
69
  const { t } = useI18n();
70
- const { oxyServices, handleWebSession, accountDialogController } = useOxy();
70
+ const {
71
+ oxyServices,
72
+ handleWebSession,
73
+ accountDialogController,
74
+ currentLanguage,
75
+ campaignAttribution,
76
+ } = useOxy();
71
77
  const snapshot = useAccountDialogSnapshot(accountDialogController);
72
78
  const flowOwner = host === 'dialog' ? accountDialogController : null;
73
79
  const [saved] = useState(() => readSignInFlow<SavedSignUpFlow>(flowOwner, SIGN_UP_FLOW_KEY));
@@ -139,6 +145,8 @@ export const OxySignUpPanel: React.FC<OxySignUpPanelProps> = ({
139
145
  username: username.trim(),
140
146
  email,
141
147
  emailTicket: ticket,
148
+ languages: [currentLanguage],
149
+ ...(campaignAttribution ? { campaignAttribution } : {}),
142
150
  });
143
151
  await handleWebSession(session);
144
152
  writeSignInFlow(flowOwner, SIGN_UP_FLOW_KEY, undefined);
@@ -62,6 +62,7 @@ import {
62
62
  import { createTokenLossRecovery, TOKEN_RECOVERY_BACKOFF_MS } from '../session/tokenLossRecovery';
63
63
  import { createCachedBootStore, type CachedBootStore } from '../session/cachedBoot';
64
64
  import { bindAuthStoreToRuntime } from '../stores/authStore';
65
+ import { useEntryLink } from '../hooks/useEntryLink';
65
66
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
66
67
  import { useSessionManagement } from '../hooks/useSessionManagement';
67
68
  import { useAuthOperations, clearPersistedAuthSafe } from './hooks/useAuthOperations';
@@ -153,6 +154,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
153
154
  sessionMode = 'account',
154
155
  webAuthMode = 'popup',
155
156
  backgroundSession = false,
157
+ legacyLanguageKeys,
158
+ captureCampaignAttribution = false,
156
159
  platformStorage,
157
160
  accountQueries,
158
161
  onAuthStateChange,
@@ -415,8 +418,10 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
415
418
  .catch((err) => {
416
419
  // Nothing persisted can be restored; the account cache still has to
417
420
  // become ready, or RequireOxyAuth would wait forever.
418
- accountQueryPersistence.setStorage(createMemoryStorage());
421
+ const memoryStorage = createMemoryStorage();
422
+ accountQueryPersistence.setStorage(memoryStorage);
419
423
  if (mounted) {
424
+ setStorage(memoryStorage);
420
425
  logger('Failed to initialize storage', err);
421
426
  onError?.({
422
427
  message: 'Failed to initialize storage',
@@ -430,8 +435,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
430
435
  };
431
436
  }, [accountQueryPersistence, logger, onError, platformStorage, runtime, storageReady]);
432
437
 
438
+ const entryLink = useEntryLink({
439
+ storage,
440
+ storageKey: `${storageKeyPrefix}_campaign_attribution`,
441
+ captureCampaign: captureCampaignAttribution,
442
+ onError: logger,
443
+ });
433
444
  const {
434
445
  currentLanguage,
446
+ isLanguageReady,
435
447
  languages: currentLanguages,
436
448
  metadata: currentLanguageMetadata,
437
449
  languageName: currentLanguageName,
@@ -441,6 +453,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
441
453
  } = useLanguageManagement({
442
454
  storage,
443
455
  languageKey: storageKeys.language,
456
+ entryUrl: entryLink.url,
457
+ entryLinkReady: entryLink.ready,
458
+ legacyLanguageKeys,
444
459
  user,
445
460
  onError,
446
461
  logger,
@@ -1751,6 +1766,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1751
1766
  webAuthMode,
1752
1767
  error,
1753
1768
  currentLanguage,
1769
+ isLanguageReady: isLanguageReady && entryLink.campaignReady,
1770
+ campaignAttribution: entryLink.campaignAttribution,
1754
1771
  currentLanguages,
1755
1772
  currentLanguageMetadata,
1756
1773
  currentLanguageName,
@@ -1808,6 +1825,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1808
1825
  webAuthMode,
1809
1826
  error,
1810
1827
  currentLanguage,
1828
+ isLanguageReady,
1829
+ entryLink.campaignReady,
1830
+ entryLink.campaignAttribution,
1811
1831
  currentLanguages,
1812
1832
  currentLanguageMetadata,
1813
1833
  currentLanguageName,
@@ -1,3 +1,4 @@
1
+ import type { CampaignAttribution } from '@oxy.so/contracts';
1
2
  import type {
2
3
  StartNativeOAuthSignInOptions,
3
4
  NativeOAuthSignInResult,
@@ -85,6 +86,8 @@ export interface OxyContextState {
85
86
  error: string | null;
86
87
  /** Active UI locale (`language-REGION`): the account's primary locale when signed in, else the guest/device locale. */
87
88
  currentLanguage: string;
89
+ isLanguageReady: boolean;
90
+ campaignAttribution?: CampaignAttribution;
88
91
  /** Ordered account locales (primary first) when signed in, or the single guest override when signed out. */
89
92
  currentLanguages: string[];
90
93
  currentLanguageMetadata: ReturnType<typeof useLanguageManagement>['metadata'];
@@ -180,6 +183,8 @@ export interface OxyContextState {
180
183
  }
181
184
 
182
185
  export interface OxyRuntimeProviderProps {
186
+ legacyLanguageKeys?: readonly string[];
187
+ captureCampaignAttribution?: boolean;
183
188
  children: ReactNode;
184
189
  oxyServices?: OxyServices;
185
190
  baseURL?: string;