diff --git a/.changeset/ype-4813-reader-locale.md b/.changeset/ype-4813-reader-locale.md
new file mode 100644
index 00000000..6a090970
--- /dev/null
+++ b/.changeset/ype-4813-reader-locale.md
@@ -0,0 +1,5 @@
+---
+'@youversion/platform-react-ui': minor
+---
+
+Hosts can pass `locale` on `YouVersionProvider` to set SDK UI language and `Accept-Language` (including Verse of the Day copy in Expo WebViews), and `defaultLanguageId` / `languageId` on `BibleReader.Root` to seed the version picker. App locale and Bible language stay separate: `locale` does not pick a default Bible translation.
diff --git a/docs/i18n-guidelines.md b/docs/i18n-guidelines.md
index 19330158..08f267bd 100644
--- a/docs/i18n-guidelines.md
+++ b/docs/i18n-guidelines.md
@@ -58,6 +58,20 @@ const { t } = useTranslation(undefined, { i18n });
Never hardcode user-facing text in JSX attributes (`aria-label`, `title`, `placeholder`, `alt`) or visible copy.
+## Host-set language
+
+By default the UI language follows `navigator.languages`. Pass `locale` on `YouVersionProvider` to set it explicitly — for example the React Native Expo SDK forwarding its provider `locale` into a WebView:
+
+```tsx
+
+
+
+```
+
+Regional tags such as `es-MX` resolve to a bundled locale (`es`). Unsupported tags fall back to English. Omit `locale` to keep browser detection. `locale` also sets `Accept-Language` on API calls unless the host already set that header.
+
+This is app locale, not Bible translation language. Seed the version picker with `defaultLanguageId` on `BibleReader.Root`. Do not map `locale` to a Bible language. No new translation keys are needed for this; existing bundles (including Spanish `verseOfTheDay`) are used as-is.
+
## Local checks
```bash
diff --git a/examples/vite-react/.env.example b/examples/vite-react/.env.example
index c2807a80..a35ea5ec 100644
--- a/examples/vite-react/.env.example
+++ b/examples/vite-react/.env.example
@@ -1,3 +1,7 @@
VITE_YVP_APP_KEY=""
VITE_YVP_API_HOST="api.youversion.com"
VITE_YVP_AUTH_REDIRECT_URL="http://localhost:5173"
+# Optional. SDK UI language (BCP-47). Leave unset to follow the browser.
+# VITE_YVP_LOCALE="es"
+# Optional. Seeds the Reader version picker Bible language. Distinct from locale.
+# VITE_YVP_DEFAULT_LANGUAGE_ID="es"
diff --git a/examples/vite-react/README.md b/examples/vite-react/README.md
index 2ccbd3e5..b8aa3b11 100644
--- a/examples/vite-react/README.md
+++ b/examples/vite-react/README.md
@@ -9,6 +9,7 @@ A demo app showcasing `@youversion/platform-react-ui` components.
```bash
cp .env.example .env.local
# Add your YouVersion App Key to .env.local
+# Optional: VITE_YVP_LOCALE and VITE_YVP_DEFAULT_LANGUAGE_ID (e.g. es)
pnpm install
pnpm dev
```
diff --git a/examples/vite-react/src/ThemedApp.tsx b/examples/vite-react/src/ThemedApp.tsx
index eeefd473..937e6089 100644
--- a/examples/vite-react/src/ThemedApp.tsx
+++ b/examples/vite-react/src/ThemedApp.tsx
@@ -9,6 +9,7 @@ export default function ThemedApp() {
const appKey = import.meta.env.VITE_YVP_APP_KEY;
const apiHost = import.meta.env.VITE_YVP_API_HOST ?? 'api.youversion.com';
const authRedirectUrl = import.meta.env.VITE_YVP_AUTH_REDIRECT_URL ?? window.location.origin;
+ const locale = import.meta.env.VITE_YVP_LOCALE?.trim() || undefined;
return (
diff --git a/examples/vite-react/src/pages/BibleReaderPage.tsx b/examples/vite-react/src/pages/BibleReaderPage.tsx
index 67bd8429..5cdf7aa1 100644
--- a/examples/vite-react/src/pages/BibleReaderPage.tsx
+++ b/examples/vite-react/src/pages/BibleReaderPage.tsx
@@ -1,9 +1,16 @@
import { BibleReader } from '@youversion/platform-react-ui';
export function BibleReaderPage() {
+ const defaultLanguageId = import.meta.env.VITE_YVP_DEFAULT_LANGUAGE_ID?.trim() || undefined;
+
return (
-
+
diff --git a/examples/vite-react/src/vite-env.d.ts b/examples/vite-react/src/vite-env.d.ts
new file mode 100644
index 00000000..41629bb8
--- /dev/null
+++ b/examples/vite-react/src/vite-env.d.ts
@@ -0,0 +1,13 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_YVP_APP_KEY?: string;
+ readonly VITE_YVP_API_HOST?: string;
+ readonly VITE_YVP_AUTH_REDIRECT_URL?: string;
+ readonly VITE_YVP_LOCALE?: string;
+ readonly VITE_YVP_DEFAULT_LANGUAGE_ID?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 8764d116..43c67395 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -41,6 +41,14 @@ function App() {
}
```
+Optional `locale` sets bundled UI copy (Verse of the Day heading, buttons, etc.) and `Accept-Language` instead of following the browser language. Regional tags like `es-MX` resolve to a bundled locale. This is app language, not Bible translation language — seed the version picker with `defaultLanguageId` on `BibleReader.Root`.
+
+```tsx
+
+
+
+```
+
### Limit which Bible versions the SDK uses
By default the version picker offers Bible versions in every available language. Limit that with `permittedLanguageTags`, `permittedVersionIds`, and `excludedVersionIds` on `YouVersionProvider`. A version must satisfy every list that is set. Exclusion wins if an id is in both permit and exclude lists. Unset means no restriction; an empty permit list permits nothing.
diff --git a/packages/ui/src/components/YouVersionProvider.test.tsx b/packages/ui/src/components/YouVersionProvider.test.tsx
index 3ff187aa..24a5d956 100644
--- a/packages/ui/src/components/YouVersionProvider.test.tsx
+++ b/packages/ui/src/components/YouVersionProvider.test.tsx
@@ -3,10 +3,12 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
+import { renderToString } from 'react-dom/server';
import React, { useContext } from 'react';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionContext } from '@youversion/platform-react-hooks';
import { YouVersionProvider } from '@/components/YouVersionProvider';
+import i18n from '@/i18n';
function AdditionalHeadersProbe(): React.ReactElement {
const headers = useContext(YouVersionContext)?.additionalHeaders;
@@ -36,6 +38,50 @@ describe('UI YouVersionProvider', () => {
expect(screen.getByTestId('headers').textContent).toBe('none');
});
+ it('sends Accept-Language from locale', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('headers').textContent).toBe(
+ JSON.stringify({ 'Accept-Language': 'es-MX' }),
+ );
+ });
+
+ it('lets additionalHeaders override Accept-Language from locale', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('headers').textContent).toBe(
+ JSON.stringify({ 'Accept-Language': 'fr', 'X-Custom': '1' }),
+ );
+ });
+
+ it('lets additionalHeaders override Accept-Language from locale regardless of header casing', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('headers').textContent).toBe(
+ JSON.stringify({ 'accept-language': 'fr', 'X-Custom': '1' }),
+ );
+ });
+
it('mirrors appName and signInPromptMessage onto the UI-bundled config', () => {
YouVersionPlatformConfiguration.appName = undefined;
YouVersionPlatformConfiguration.signInPromptMessage = undefined;
@@ -81,4 +127,44 @@ describe('UI YouVersionProvider', () => {
errorSpy.mockRestore();
},
);
+
+ it('uses locale instead of the browser language', async () => {
+ vi.stubGlobal('navigator', {
+ language: 'en-US',
+ languages: ['en-US', 'en'],
+ });
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(i18n.language).toBe('es');
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(i18n.language).toBe('es');
+
+ await i18n.changeLanguage('en');
+ vi.unstubAllGlobals();
+ });
+
+ it('applies locale during SSR without waiting for layout effects', async () => {
+ await i18n.changeLanguage('en');
+
+ renderToString(
+
+
+ ,
+ );
+
+ expect(i18n.language).toBe('es');
+
+ await i18n.changeLanguage('en');
+ });
});
diff --git a/packages/ui/src/components/YouVersionProvider.tsx b/packages/ui/src/components/YouVersionProvider.tsx
index 85bd1548..17922bbc 100644
--- a/packages/ui/src/components/YouVersionProvider.tsx
+++ b/packages/ui/src/components/YouVersionProvider.tsx
@@ -1,7 +1,7 @@
-import React, { type ComponentProps, Suspense, useEffect } from 'react';
+import React, { type ComponentProps, Suspense, useEffect, useLayoutEffect } from 'react';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionProvider as BaseYouVersionProvider } from '@youversion/platform-react-hooks';
-import { syncBrowserLanguageFromNavigator } from '@/i18n';
+import { syncSdkLanguage } from '@/i18n';
import { YvStyles } from '@/lib/yv-styles';
import { YvFonts } from '@/lib/yv-fonts';
import { MissingAppKey } from '@/components/missing-app-key';
@@ -12,12 +12,38 @@ function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | '
return globalThis.window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
-export function YouVersionProvider(
- props: ComponentProps,
-): React.ReactElement {
- useEffect(() => {
- syncBrowserLanguageFromNavigator();
- }, []);
+export type YouVersionProviderProps = ComponentProps & {
+ /**
+ * BCP-47 tag for SDK UI strings and the `Accept-Language` header on API
+ * calls. When omitted, UI language follows the browser and API language
+ * stays the server default unless the host sets `Accept-Language` in
+ * `additionalHeaders`.
+ *
+ * This is app locale, not Bible translation language. Seed the version
+ * picker with `defaultLanguageId` on `BibleReader.Root` instead of mapping
+ * `locale` to a Bible language.
+ */
+ locale?: string;
+};
+
+export function YouVersionProvider({
+ locale,
+ additionalHeaders,
+ ...props
+}: YouVersionProviderProps): React.ReactElement {
+ const normalizedLocale = locale?.trim() || undefined;
+
+ // Layout effects never run during SSR. Apply an explicit locale during render
+ // so children emit the host language in the server HTML and the first client
+ // paint matches it. When locale is omitted, wait for the layout effect so SSR
+ // stays on the English fallback instead of a request-time browser language.
+ if (normalizedLocale) {
+ void syncSdkLanguage(normalizedLocale);
+ }
+
+ useLayoutEffect(() => {
+ void syncSdkLanguage(normalizedLocale);
+ }, [normalizedLocale]);
// UI tsup inlines `@youversion/platform-core`, so this singleton is a different
// copy from the one hooks syncs. BibleReader reads appName / signInPromptMessage
@@ -60,8 +86,18 @@ export function YouVersionProvider(
);
}
+ let mergedHeaders = additionalHeaders;
+ if (normalizedLocale) {
+ const hostSetsAcceptLanguage = Object.keys(additionalHeaders ?? {}).some(
+ (key) => key.toLowerCase() === 'accept-language',
+ );
+ if (!hostSetsAcceptLanguage) {
+ mergedHeaders = { 'Accept-Language': normalizedLocale, ...additionalHeaders };
+ }
+ }
+
return (
-
+
{/* Only in this branch — the missing-app-key guard above has no key, and
without a key the gated Fonts API request would 401.
diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx
index 8b71ac6a..119c0ffe 100644
--- a/packages/ui/src/components/bible-reader.test.tsx
+++ b/packages/ui/src/components/bible-reader.test.tsx
@@ -71,6 +71,28 @@ function renderWithOverrides(ui: ReactElement) {
return render({ui});
}
+function overridesRecordingVersionLanguage() {
+ const requestedLanguages: string[] = [];
+ const overrides = {
+ ...defaultOverrides(),
+ useVersions: (languageRanges?: string | string[]) => {
+ if (languageRanges !== undefined && !Array.isArray(languageRanges)) {
+ requestedLanguages.push(languageRanges);
+ }
+ return {
+ versions: { data: [], next_page_token: null },
+ loading: false,
+ error: null,
+ refetch: () => undefined,
+ };
+ },
+ } satisfies HookOverrides;
+ return {
+ requestedLanguages,
+ overrides,
+ };
+}
+
const mockBooks: BibleBook[] = [
{
id: 'JHN',
@@ -407,3 +429,43 @@ describe('BibleReader Toolbar - onChapterPickerPress', () => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
});
+
+describe('BibleReader version picker language', () => {
+ it('seeds the version picker with defaultLanguageId instead of the browser language', () => {
+ const { overrides, requestedLanguages } = overridesRecordingVersionLanguage();
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(requestedLanguages.includes('es')).toBe(true);
+ });
+
+ it('uses a controlled languageId for the version picker', () => {
+ const { overrides, requestedLanguages } = overridesRecordingVersionLanguage();
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(requestedLanguages.includes('ko')).toBe(true);
+ });
+});
diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx
index 470e5f84..3daf30c3 100644
--- a/packages/ui/src/components/bible-reader.tsx
+++ b/packages/ui/src/components/bible-reader.tsx
@@ -78,6 +78,9 @@ type BibleReaderContextType = {
onFootnotePress?: (data: FootnoteData) => void;
onChapterPickerPress?: (data: BibleChapterPickerPressData) => void;
onVersionPickerPress?: (data: BibleVersionPickerPressData) => void;
+ languageId?: string;
+ defaultLanguageId?: string;
+ onLanguageChange?: (languageId: string) => void;
onSignInPress?: () => void;
onSignOutPress?: () => void;
onCopy?: (data: BibleReaderShareData) => void | Promise;
@@ -211,6 +214,22 @@ export type RootProps = {
onFootnotePress?: (data: FootnoteData) => void;
onChapterPickerPress?: (data: BibleChapterPickerPressData) => void;
onVersionPickerPress?: (data: BibleVersionPickerPressData) => void;
+ /**
+ * Bible translation language for the version picker (`en`, `es`, …).
+ * Controlled when set with `onLanguageChange`.
+ *
+ * Distinct from `YouVersionProvider` `locale`, which is app UI language.
+ * Do not derive this from device locale unless the host intends the picker
+ * to open on that Bible language.
+ */
+ languageId?: string;
+ /**
+ * Uncontrolled initial Bible translation language for the version picker.
+ * Ignored when `languageId` is set. When omitted, the picker falls back to
+ * the browser language, then `en`.
+ */
+ defaultLanguageId?: string;
+ onLanguageChange?: (languageId: string) => void;
onSignInPress?: () => void;
onSignOutPress?: () => void;
/**
@@ -451,6 +470,9 @@ function Root({
onFootnotePress,
onChapterPickerPress,
onVersionPickerPress,
+ languageId,
+ defaultLanguageId,
+ onLanguageChange,
onSignInPress,
onSignOutPress,
onCopy,
@@ -626,6 +648,9 @@ function Root({
onFootnotePress,
onChapterPickerPress,
onVersionPickerPress,
+ languageId,
+ defaultLanguageId,
+ onLanguageChange,
onSignInPress,
onSignOutPress,
onCopy,
@@ -1358,6 +1383,9 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba
background,
onChapterPickerPress,
onVersionPickerPress,
+ languageId,
+ defaultLanguageId,
+ onLanguageChange,
} = useBibleReaderContext();
const yvContext = useContext(YouVersionContext);
const themesSettingsValuesRef = useRef({
@@ -1523,6 +1551,9 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba
diff --git a/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx b/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx
index 16b3991a..398c543c 100644
--- a/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx
+++ b/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx
@@ -95,14 +95,21 @@ describe('vapor flash — real Verse.Html DOM paint (MutationObserver on style)'
);
const verseEl = () => container.querySelector('.yv-v[v="2"]');
- // Wait until server truth has painted verse 2 yellow.
+ // Wait until server truth has painted verse 2 yellow and the remove
+ // callback is installed. Verse.Html paints from the hook in useLayoutEffect;
+ // HIGHLIGHTS_UPDATED reaches the machine in a later useEffect. Calling
+ // remove before that send lands is a no-op (empty serverColors → no write).
await waitFor(
() => {
const bg = verseEl()?.style.backgroundColor ?? '';
expect(bg).not.toBe('');
+ expect(removeRef.current).toEqual(expect.any(Function));
},
{ timeout: 5000 },
);
+ await act(async () => {
+ await Promise.resolve();
+ });
const mountFetches = getHighlights.mock.calls.length;
// Instrument: record every background-color the verse-2 wrapper takes on
@@ -114,9 +121,13 @@ describe('vapor flash — real Verse.Html DOM paint (MutationObserver on style)'
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
+ const remove = removeRef.current;
+ if (!remove) {
+ throw new Error('remove callback was not installed');
+ }
act(() => {
removed = true;
- removeRef.current?.();
+ remove();
});
// These two waits are synchronization gates, not the assertions under test
diff --git a/packages/ui/src/i18n/index.test.ts b/packages/ui/src/i18n/index.test.ts
index db9ccf1a..3ee29cf6 100644
--- a/packages/ui/src/i18n/index.test.ts
+++ b/packages/ui/src/i18n/index.test.ts
@@ -234,4 +234,24 @@ describe('i18n instance', () => {
expect(i18n.language).toBe('en');
expect(i18n.t('verseOfTheDay')).toBe(en.verseOfTheDay);
});
+
+ it('applies an explicit locale over navigator language', async () => {
+ vi.stubGlobal('navigator', {
+ language: 'en-US',
+ languages: ['en-US', 'en'],
+ });
+ vi.resetModules();
+
+ const i18n = await loadI18n();
+ const { syncSdkLanguage } = await import('./index');
+ await syncSdkLanguage('fr-FR');
+
+ expect(i18n.language).toBe('fr');
+ expect(i18n.t('verseOfTheDay')).toBe(resources.fr.translation.verseOfTheDay);
+
+ await syncSdkLanguage('es-MX');
+
+ expect(i18n.language).toBe('es');
+ expect(i18n.t('verseOfTheDay')).toBe(resources.es.translation.verseOfTheDay);
+ });
});
diff --git a/packages/ui/src/i18n/index.ts b/packages/ui/src/i18n/index.ts
index 1a556799..fde8d35e 100644
--- a/packages/ui/src/i18n/index.ts
+++ b/packages/ui/src/i18n/index.ts
@@ -12,16 +12,38 @@ const fallbackLng = 'en';
const i18n: I18nInstance = i18next.createInstance();
+/**
+ * Resolves a host-supplied or browser language tag to a bundled locale and
+ * applies it to the SDK i18n instance.
+ *
+ * Pass a BCP-47 tag (e.g. `es-MX`) when the host owns language — React Native
+ * Expo WebViews often report English in `navigator` even when the device is not.
+ * Omit the tag to follow the browser, matching {@link syncBrowserLanguageFromNavigator}.
+ *
+ * Call from YouVersionProvider — do not rely on module-load detection, which
+ * runs in Node during bundling/dep optimization and locks to fallbackLng.
+ */
+export function syncSdkLanguage(languageTag?: string): Promise {
+ let tags: readonly string[] | undefined;
+ if (languageTag === undefined) {
+ tags = getBrowserLanguages();
+ } else {
+ tags = [languageTag];
+ }
+
+ const detected = resolveBrowserLanguage(tags, supportedLngs, fallbackLng);
+ if (i18n.language === detected) {
+ return Promise.resolve(detected);
+ }
+ return i18n.changeLanguage(detected).then(() => detected);
+}
+
/**
* Applies the user's browser language when running in a browser.
- * Call from YouVersionProvider on mount — do not rely on module-load detection,
- * which runs in Node during bundling/dep optimization and locks to fallbackLng.
+ * Call from YouVersionProvider on mount when no `locale` prop is set.
*/
export function syncBrowserLanguageFromNavigator(): void {
- const detected = resolveBrowserLanguage(getBrowserLanguages(), supportedLngs, fallbackLng);
- if (i18n.language !== detected) {
- void i18n.changeLanguage(detected);
- }
+ void syncSdkLanguage();
}
function getInitialLanguage(): string {
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 7eaf5116..2bdf8366 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -23,4 +23,4 @@ export {
type UseYVAuthReturn,
} from '@youversion/platform-react-hooks';
-export { YouVersionProvider } from './components/YouVersionProvider';
+export { YouVersionProvider, type YouVersionProviderProps } from './components/YouVersionProvider';