diff --git a/.changeset/ype-5745-reader-navigation.md b/.changeset/ype-5745-reader-navigation.md
new file mode 100644
index 00000000..a050b5bc
--- /dev/null
+++ b/.changeset/ype-5745-reader-navigation.md
@@ -0,0 +1,5 @@
+---
+'@youversion/platform-react-native-expo-ui': minor
+---
+
+feat: add BibleReaderNavigation for chapter jumps (YPE-5745). Host can request a version/book/chapter before the Reader mounts. Scroll-to-verse and focus are not in this release.
diff --git a/CONTEXT.md b/CONTEXT.md
index 2683a2ed..09016edb 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -48,7 +48,11 @@ _Avoid_: Passage id, USFM ref
**Reader Location**:
The last committed Bible location (`book`, `chapter`, `versionId`) a **Native Wrapper** restores on launch for uncontrolled readers. Same shape as **Picker Selection**, but names the persisted snapshot rather than the commit event. Controlled `book` / `chapter` / `versionId` win and are not overwritten by the snapshot. Uncontrolled **BibleCard** persists committed `versionId` in MMKV, separate from this snapshot.
-_Avoid_: Reader navigation, passage state
+_Avoid_: **Reader Navigation** (the pending-request object); passage state
+
+**Reader Navigation**:
+The public pending-request object (`BibleReaderNavigation`) a host creates and passes into `BibleReader`. One object per mounted Reader. `request` and `focusReference` queue one jump to a version / book / chapter; a newer call replaces an older one; the Reader consumes it once, including when submitted before mount. Verse scroll and focus fields are stored for a later release and do not move or dim the chapter today. Goes through the existing `book` / `chapter` / `versionId` setters, so controlled props still notify the host and uncontrolled readers still persist **Reader Location**.
+_Avoid_: Adding methods to **BibleReaderHandle**; new DOM / WebView props; treating this as **Reader Location** (that is the MMKV snapshot)
**Picker Press**:
The user action that requests opening chapter picker presentation from the current Bible location. Defaults to opening the built-in **Chapter Picker Sheet**; overridable via `onChapterPickerPress`.
diff --git a/README.md b/README.md
index e3b4d97f..2c996978 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,27 @@ On iOS and Android, avatar, chapter (with prev/next), version, and settings live
`BibleTextView`, `BibleCard`, and `VerseOfTheDay` paint those same highlights on the passage they show, from the same cache. They do not create or remove highlights — tapping a verse on those surfaces still does nothing.
+#### Jumping to a passage
+
+Create a `BibleReaderNavigation` object and pass it in. Use one object per Reader. You can call it before the reader mounts. A newer call replaces an older one; the reader consumes each request once.
+
+```tsx
+import { useMemo } from 'react'
+import { BibleReader, createBibleReaderNavigation } from '@youversion/platform-react-native-expo-ui'
+
+function ReaderScreen() {
+ const navigation = useMemo(() => {
+ const readerNavigation = createBibleReaderNavigation()
+ readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 })
+ return readerNavigation
+ }, [])
+
+ return
+}
+```
+
+`request` and `focusReference` load that version / book / chapter. They do not scroll to the verse or dim the rest of the chapter in this release.
+
#### Verse actions
Tapping a verse opens a native bottom sheet with the reference, Copy, and Share. When `auth` is configured on `YouVersionProvider`, the sheet also shows the highlight colors. It is the same surface the [Swift](https://github.com/youversion/platform-sdk-swift) and [Kotlin](https://github.com/youversion/platform-sdk-kotlin) SDKs present. It is on by default and needs no props.
diff --git a/packages/ui/src/__tests__/exports.test.ts b/packages/ui/src/__tests__/exports.test.ts
index 5d4f7e53..4953923f 100644
--- a/packages/ui/src/__tests__/exports.test.ts
+++ b/packages/ui/src/__tests__/exports.test.ts
@@ -1,8 +1,9 @@
/**
* Guards the public API surface of the UI package, the way
* `packages/core/src/highlights/__tests__/exports.test.ts` guards core's. The
- * nine components plus `useSignOutGuard`, `getTokens`, and `useTokens` are the supported
- * surface; the sheets the SDK wires for itself stay off the package namespace,
+ * nine components plus `BibleReaderNavigation`, `createBibleReaderNavigation`,
+ * `useSignOutGuard`, `getTokens`, and `useTokens` are the supported surface;
+ * the sheets the SDK wires for itself stay off the package namespace,
* so a consumer cannot couple to them and an accidental re-export from
* `index.ts` reds the suite.
*
@@ -28,7 +29,7 @@ describe('package exports', () => {
// Named one by one rather than looped over `PUBLIC_COMPONENTS`: a computed
// read off the namespace is something neither ESLint nor tsc can check, so
// the loop would pass on a name no longer exported.
- it('exposes the public components, the sign-out guard, getTokens, and useTokens', () => {
+ it('exposes the public components, reader navigation, the sign-out guard, getTokens, and useTokens', () => {
expect(ui.BibleCard).toEqual(expect.any(Function))
expect(ui.BibleChapterPickerSheet).toEqual(expect.any(Function))
expect(ui.BibleReader).toEqual(expect.any(Function))
@@ -38,6 +39,8 @@ describe('package exports', () => {
expect(ui.VerseOfTheDay).toEqual(expect.any(Function))
expect(ui.YouVersionAuthButton).toEqual(expect.any(Function))
expect(ui.YouVersionProvider).toEqual(expect.any(Function))
+ expect(ui.BibleReaderNavigation).toEqual(expect.any(Function))
+ expect(ui.createBibleReaderNavigation).toEqual(expect.any(Function))
expect(ui.useSignOutGuard).toEqual(expect.any(Function))
expect(ui.getTokens).toEqual(expect.any(Function))
expect(ui.useTokens).toEqual(expect.any(Function))
@@ -55,6 +58,7 @@ describe('package exports', () => {
expect(names).not.toContain('useVerseOfTheDayPassageId')
expect(names).not.toContain('getVerseOfTheDayPassageId')
expect(names).not.toContain('getDayOfYear')
+ expect(names).not.toContain('useConsumedNavigationRequest')
expect(names).not.toContain('useVerseOfTheDayShareSource')
expect(names).not.toContain('getVerseOfTheDayShareSource')
expect(names).not.toContain('Tabs')
@@ -72,7 +76,14 @@ describe('package exports', () => {
it('exports nothing beyond the pinned list', () => {
expect(Object.keys(ui).sort()).toEqual(
- [...PUBLIC_COMPONENTS, 'getTokens', 'useSignOutGuard', 'useTokens'].sort(),
+ [
+ ...PUBLIC_COMPONENTS,
+ 'BibleReaderNavigation',
+ 'createBibleReaderNavigation',
+ 'getTokens',
+ 'useSignOutGuard',
+ 'useTokens',
+ ].sort(),
)
})
})
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 591891ee..323f56c2 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -6,17 +6,20 @@ export {
BibleCard,
BibleChapterPickerSheet,
BibleReader,
+ BibleReaderNavigation,
BibleReaderSettingsSheet,
BibleTextView,
BibleVersionPickerSheet,
VerseOfTheDay,
YouVersionAuthButton,
YouVersionProvider,
+ createBibleReaderNavigation,
} from './native'
export type {
BibleCardProps,
BibleChapterPickerSheetProps,
BibleReaderHandle,
+ BibleReaderNavigationRequest,
BibleReaderProps,
BibleReaderSettingsSheetProps,
BibleReaderShareData,
diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx
new file mode 100644
index 00000000..14db8c65
--- /dev/null
+++ b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx
@@ -0,0 +1,231 @@
+import { act, fireEvent, render } from '@testing-library/react-native'
+import { mmkvStorage } from '@youversion/platform-react-native-expo-core'
+import { Pressable, Text, View } from 'react-native'
+
+import {
+ readerLocationStoreInitialState,
+ useReaderLocationStore,
+} from '../../stores/reader-location-store'
+import {
+ installBibleReaderTestImpls,
+ resetImpls,
+ setImpl,
+} from '../../test-utils/install-test-impls'
+import { youVersionProviderWrapper } from '../../test-utils/youversion-provider-wrapper'
+import { BibleReader } from '../bible-reader'
+import { createBibleReaderNavigation } from '../bible-reader-navigation'
+
+type LatestReaderDomProps = {
+ book?: string
+ chapter?: string
+ versionId?: number
+ onChapterChange?: (chapter: string) => Promise
+}
+
+let latestReaderDomProps: LatestReaderDomProps = {}
+
+function MockDOM(props: LatestReaderDomProps) {
+ latestReaderDomProps = props
+ return (
+
+ {props.book ?? 'none'}
+ {props.chapter ?? 'none'}
+ {String(props.versionId ?? 'none')}
+ props.onChapterChange?.('5')}>
+ Chapter
+
+
+ )
+}
+
+const wrapper = youVersionProviderWrapper()
+
+async function resetReaderLocationStore() {
+ mmkvStorage.clearAll()
+ useReaderLocationStore.setState(readerLocationStoreInitialState)
+ await useReaderLocationStore.persist.rehydrate()
+}
+
+describe('BibleReader navigation', () => {
+ beforeEach(async () => {
+ latestReaderDomProps = {}
+ installBibleReaderTestImpls()
+ setImpl('BibleReaderDom', MockDOM)
+ await resetReaderLocationStore()
+ })
+
+ afterEach(() => {
+ resetImpls()
+ jest.restoreAllMocks()
+ })
+
+ it('applies a request made before mount on the first render', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 })
+
+ const { getByTestId } = render(, { wrapper })
+
+ expect(getByTestId('book').props.children).toBe('PSA')
+ expect(getByTestId('chapter').props.children).toBe('23')
+ expect(getByTestId('version-id').props.children).toBe('111')
+ })
+
+ it('lets a newer request replace an older one before mount', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request({ versionId: 59, bookId: 'GEN', chapter: 1, verse: 1 })
+ navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 })
+
+ const { getByTestId } = render(, { wrapper })
+
+ expect(getByTestId('book').props.children).toBe('PSA')
+ expect(getByTestId('chapter').props.children).toBe('23')
+ expect(getByTestId('version-id').props.children).toBe('111')
+ })
+
+ it('consumes a request once so a later render does not re-apply it', async () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 })
+
+ const { getByTestId, rerender } = render(, { wrapper })
+
+ expect(getByTestId('book').props.children).toBe('PSA')
+ expect(getByTestId('chapter').props.children).toBe('23')
+
+ rerender()
+
+ expect(getByTestId('chapter').props.children).toBe('23')
+
+ await act(async () => {
+ fireEvent.press(getByTestId('trigger-chapter-change'))
+ })
+
+ expect(getByTestId('book').props.children).toBe('PSA')
+ expect(getByTestId('chapter').props.children).toBe('5')
+ expect(getByTestId('version-id').props.children).toBe('111')
+ })
+
+ it('request and focusReference both change chapter and version when the reference differs', async () => {
+ const navigation = createBibleReaderNavigation()
+
+ const { getByTestId } = render(
+ ,
+ { wrapper },
+ )
+
+ expect(getByTestId('book').props.children).toBe('JHN')
+ expect(getByTestId('chapter').props.children).toBe('1')
+ expect(getByTestId('version-id').props.children).toBe('111')
+
+ await act(async () => {
+ navigation.request({ versionId: 111, bookId: 'MAT', chapter: 5, verse: 1 })
+ })
+
+ expect(getByTestId('book').props.children).toBe('MAT')
+ expect(getByTestId('chapter').props.children).toBe('5')
+ expect(getByTestId('version-id').props.children).toBe('111')
+
+ await act(async () => {
+ navigation.focusReference({ versionId: 59, bookId: 'ROM', chapter: 8, verse: 1 })
+ })
+
+ expect(getByTestId('book').props.children).toBe('ROM')
+ expect(getByTestId('chapter').props.children).toBe('8')
+ expect(getByTestId('version-id').props.children).toBe('59')
+ })
+
+ it('persists a combined jump as one Reader Location', async () => {
+ const originalSetLocation = useReaderLocationStore.getState().setLocation
+ const patches: { book?: string; chapter?: string; versionId?: number }[] = []
+
+ try {
+ await act(async () => {
+ useReaderLocationStore.setState({
+ setLocation: (patch) => {
+ patches.push(patch)
+ originalSetLocation(patch)
+ },
+ })
+ })
+
+ const navigation = createBibleReaderNavigation()
+ render(
+ ,
+ { wrapper },
+ )
+ patches.length = 0
+
+ await act(async () => {
+ navigation.request({ versionId: 59, bookId: 'ROM', chapter: 8, verse: 1 })
+ })
+
+ expect(patches).toEqual([{ book: 'ROM', chapter: '8', versionId: 59 }])
+ expect(useReaderLocationStore.getState()).toMatchObject({
+ book: 'ROM',
+ chapter: '8',
+ versionId: 59,
+ })
+ } finally {
+ await act(async () => {
+ useReaderLocationStore.setState({ setLocation: originalSetLocation })
+ })
+ }
+ })
+
+ it('consumes a same-location request without rewriting the DOM location', async () => {
+ const navigation = createBibleReaderNavigation()
+ const onBookChange = jest.fn()
+ const onChapterChange = jest.fn()
+ const onVersionChange = jest.fn()
+
+ const { getByTestId } = render(
+ ,
+ { wrapper },
+ )
+
+ await act(async () => {
+ navigation.request({ versionId: 111, bookId: 'JHN', chapter: 1, verse: 16 })
+ })
+
+ expect(getByTestId('book').props.children).toBe('JHN')
+ expect(getByTestId('chapter').props.children).toBe('1')
+ expect(getByTestId('version-id').props.children).toBe('111')
+ expect(onBookChange).not.toHaveBeenCalled()
+ expect(onChapterChange).not.toHaveBeenCalled()
+ expect(onVersionChange).not.toHaveBeenCalled()
+ })
+
+ it('does not add scroll or focus props on the DOM mock', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 })
+ navigation.focusReference({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }, true)
+
+ render(, { wrapper })
+
+ expect(latestReaderDomProps.book).toBe('JHN')
+ expect(latestReaderDomProps.chapter).toBe('3')
+ expect(latestReaderDomProps.versionId).toBe(111)
+ expect(latestReaderDomProps).not.toHaveProperty('scrollsToVerse')
+ expect(latestReaderDomProps).not.toHaveProperty('shouldFocus')
+ expect(latestReaderDomProps).not.toHaveProperty('scrollTarget')
+ expect(latestReaderDomProps).not.toHaveProperty('focusedVerse')
+ expect(latestReaderDomProps).not.toHaveProperty('showsFullChapter')
+ })
+})
diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts
new file mode 100644
index 00000000..9d32576a
--- /dev/null
+++ b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts
@@ -0,0 +1,138 @@
+import type { BibleReference } from '@youversion/platform-react-native-expo-core'
+import { act, renderHook } from '@testing-library/react-native'
+
+import {
+ BibleReaderNavigation,
+ createBibleReaderNavigation,
+ useConsumedNavigationRequest,
+} from '../bible-reader-navigation'
+
+const JOHN_3_16: BibleReference = {
+ versionId: 111,
+ bookId: 'JHN',
+ chapter: 3,
+ verse: 16,
+}
+
+const ROMANS_8_1: BibleReference = {
+ versionId: 59,
+ bookId: 'ROM',
+ chapter: 8,
+ verse: 1,
+}
+
+describe('BibleReaderNavigation', () => {
+ it('request stores a full-chapter pending request including the verse', () => {
+ const navigation = createBibleReaderNavigation()
+
+ navigation.request(JOHN_3_16)
+
+ const { result } = renderHook(() => useConsumedNavigationRequest(navigation))
+
+ expect(result.current).toEqual({
+ reference: JOHN_3_16,
+ showsFullChapter: true,
+ scrollsToVerse: false,
+ shouldFocus: false,
+ })
+ })
+
+ it('focusReference stores shouldFocus and scrollsToVerse without scrolling itself', () => {
+ const navigation = createBibleReaderNavigation()
+
+ navigation.focusReference(JOHN_3_16)
+
+ const { result } = renderHook(() => useConsumedNavigationRequest(navigation))
+
+ expect(result.current).toEqual({
+ reference: JOHN_3_16,
+ showsFullChapter: false,
+ scrollsToVerse: true,
+ shouldFocus: true,
+ })
+
+ act(() => {
+ navigation.focusReference(ROMANS_8_1, false)
+ })
+
+ expect(result.current).toEqual({
+ reference: ROMANS_8_1,
+ showsFullChapter: false,
+ scrollsToVerse: false,
+ shouldFocus: true,
+ })
+ })
+
+ it('a newer call replaces the older pending request', () => {
+ const navigation = createBibleReaderNavigation()
+
+ navigation.request(JOHN_3_16)
+ navigation.focusReference(ROMANS_8_1, true)
+
+ const { result } = renderHook(() => useConsumedNavigationRequest(navigation))
+
+ expect(result.current?.reference).toEqual(ROMANS_8_1)
+ expect(result.current?.shouldFocus).toBe(true)
+ })
+
+ it('keeps verse fields on an uncommitted pending request', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request(JOHN_3_16)
+
+ const { result } = renderHook(() => useConsumedNavigationRequest(navigation))
+
+ expect(result.current?.reference.verse).toBe(16)
+ expect(result.current?.scrollsToVerse).toBe(false)
+ expect(result.current?.shouldFocus).toBe(false)
+ })
+
+ it('createBibleReaderNavigation and new BibleReaderNavigation share the host API', () => {
+ const created = createBibleReaderNavigation()
+ const constructed = new BibleReaderNavigation()
+
+ expect(created).toBeInstanceOf(BibleReaderNavigation)
+ expect(constructed).toBeInstanceOf(BibleReaderNavigation)
+ expect(created.request).toEqual(expect.any(Function))
+ expect(created.focusReference).toEqual(expect.any(Function))
+ expect(created).not.toHaveProperty('pendingRequest')
+ expect(created).not.toHaveProperty('subscribe')
+ expect(created).not.toHaveProperty('getSnapshot')
+ expect(created).not.toHaveProperty('consumePending')
+ expect(created).not.toHaveProperty('consumeCommitted')
+ })
+})
+
+describe('useConsumedNavigationRequest', () => {
+ it('applies a pre-mount request once and keeps verse fields on that request', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request(JOHN_3_16)
+
+ const { result, rerender } = renderHook(
+ ({ nav }: { nav: BibleReaderNavigation }) => useConsumedNavigationRequest(nav),
+ { initialProps: { nav: navigation } },
+ )
+
+ expect(result.current?.reference).toEqual(JOHN_3_16)
+ expect(result.current?.reference.verse).toBe(16)
+
+ rerender({ nav: navigation })
+
+ expect(result.current).toBeNull()
+ })
+
+ it('does not drop a newer request when an older render has already committed', () => {
+ const navigation = createBibleReaderNavigation()
+ navigation.request(JOHN_3_16)
+
+ const { result } = renderHook(() => useConsumedNavigationRequest(navigation))
+
+ expect(result.current?.reference).toEqual(JOHN_3_16)
+
+ act(() => {
+ navigation.focusReference(ROMANS_8_1)
+ })
+
+ expect(result.current?.reference).toEqual(ROMANS_8_1)
+ expect(result.current?.shouldFocus).toBe(true)
+ })
+})
diff --git a/packages/ui/src/native/bible-reader-navigation.ts b/packages/ui/src/native/bible-reader-navigation.ts
new file mode 100644
index 00000000..18a1d4ca
--- /dev/null
+++ b/packages/ui/src/native/bible-reader-navigation.ts
@@ -0,0 +1,166 @@
+import type { BibleReference } from '@youversion/platform-react-native-expo-core'
+import { useLayoutEffect, useRef, useSyncExternalStore } from 'react'
+
+/**
+ * One jump the host asked the reader to make. Verse scroll and focus are
+ * stored so a later release can honor them; this release only loads the
+ * chapter.
+ */
+export type BibleReaderNavigationRequest = {
+ reference: BibleReference
+ showsFullChapter: boolean
+ scrollsToVerse: boolean
+ shouldFocus: boolean
+}
+
+type Subscribe = (onStoreChange: () => void) => () => void
+
+const subscribeNoop: Subscribe = () => () => {}
+const snapshotZero = (): number => 0
+
+function copyReference(reference: BibleReference): BibleReference {
+ return {
+ versionId: reference.versionId,
+ bookId: reference.bookId,
+ chapter: reference.chapter,
+ verse: reference.verse,
+ }
+}
+
+type ReaderNavigationAccess = {
+ subscribe: Subscribe
+ getSnapshot: () => number
+ peekPending: () => BibleReaderNavigationRequest | null
+ consumeCommitted: (version: number) => void
+}
+
+const readerAccess = new WeakMap()
+
+function accessFor(navigation: BibleReaderNavigation): ReaderNavigationAccess {
+ const access = readerAccess.get(navigation)
+ if (!access) {
+ throw new Error('BibleReaderNavigation is not initialized')
+ }
+ return access
+}
+
+/**
+ * Pending-request object a host can create and call before ``
+ * mounts. Pass one object to one Reader. One pending request; a newer call
+ * replaces an older one; the reader consumes it once.
+ *
+ * ```tsx
+ * const navigation = useMemo(() => {
+ * const readerNavigation = createBibleReaderNavigation()
+ * readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 })
+ * return readerNavigation
+ * }, [])
+ *
+ * ```
+ */
+export class BibleReaderNavigation {
+ #pending: BibleReaderNavigationRequest | null = null
+ #version = 0
+ #pendingVersion = 0
+ #listeners = new Set<() => void>()
+
+ constructor() {
+ readerAccess.set(this, {
+ subscribe: (onStoreChange) => {
+ this.#listeners.add(onStoreChange)
+ return () => {
+ this.#listeners.delete(onStoreChange)
+ }
+ },
+ getSnapshot: () => this.#version,
+ peekPending: () => this.#pending,
+ consumeCommitted: (version) => {
+ if (this.#pendingVersion !== version) {
+ return
+ }
+ this.#pending = null
+ },
+ })
+ }
+
+ /**
+ * Load this version / book / chapter as a full chapter. Verse fields stay on
+ * the pending request for a later scroll/focus pass.
+ */
+ request(reference: BibleReference): void {
+ this.#setPending({
+ reference: copyReference(reference),
+ showsFullChapter: true,
+ scrollsToVerse: false,
+ shouldFocus: false,
+ })
+ }
+
+ /**
+ * Same chapter change as {@link BibleReaderNavigation.request} today. Does
+ * not dim or scroll. `scrollsToVerse` and `shouldFocus` are stored for a
+ * later release.
+ */
+ focusReference(reference: BibleReference, scrollsToVerse = true): void {
+ this.#setPending({
+ reference: copyReference(reference),
+ showsFullChapter: false,
+ scrollsToVerse,
+ shouldFocus: true,
+ })
+ }
+
+ #setPending(request: BibleReaderNavigationRequest): void {
+ this.#pending = request
+ this.#version += 1
+ this.#pendingVersion = this.#version
+ for (const listener of this.#listeners) {
+ listener()
+ }
+ }
+}
+
+export function createBibleReaderNavigation(): BibleReaderNavigation {
+ return new BibleReaderNavigation()
+}
+
+/**
+ * Subscribe to a navigation object and consume at most one request per
+ * version bump. Peek during render so a discarded render cannot drop the
+ * jump; clear only after commit. A call before mount still lands on the
+ * first reader render.
+ */
+export function useConsumedNavigationRequest(
+ navigation: BibleReaderNavigation | undefined,
+): BibleReaderNavigationRequest | null {
+ let subscribe: Subscribe = subscribeNoop
+ let getSnapshot = snapshotZero
+ if (navigation) {
+ const access = accessFor(navigation)
+ subscribe = access.subscribe
+ getSnapshot = access.getSnapshot
+ }
+ const version = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+ const committedRef = useRef<{
+ navigation: BibleReaderNavigation | undefined
+ version: number
+ }>({ navigation: undefined, version: 0 })
+
+ useLayoutEffect(() => {
+ committedRef.current = { navigation, version }
+ if (!navigation) {
+ return
+ }
+ accessFor(navigation).consumeCommitted(version)
+ }, [navigation, version])
+
+ if (!navigation) {
+ return null
+ }
+ const committedVersion =
+ committedRef.current.navigation === navigation ? committedRef.current.version : 0
+ if (version === committedVersion) {
+ return null
+ }
+ return accessFor(navigation).peekPending()
+}
diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx
index c5d34332..fab6e0e4 100644
--- a/packages/ui/src/native/bible-reader.tsx
+++ b/packages/ui/src/native/bible-reader.tsx
@@ -21,6 +21,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -48,6 +49,7 @@ import { resolveVerseActions } from '../lib/resolve-verse-actions'
import { buildVerseActionSwatches, type VerseActionSwatch } from '../lib/verse-action-swatches'
import { useReaderLocationStore } from '../stores/reader-location-store'
import { useReaderSettingsStore } from '../stores/reader-settings-store'
+import { useConsumedNavigationRequest, type BibleReaderNavigation } from './bible-reader-navigation'
import { BibleChapterPickerSheet } from './bible-chapter-picker-sheet'
import { BibleReaderSettingsSheet } from './bible-reader-settings-sheet'
import { BibleReaderToolbar } from './bible-reader-toolbar'
@@ -195,6 +197,16 @@ export type BibleReaderProps = Omit<
* as an ordinary prop, so there is no `forwardRef` here.
*/
ref?: Ref
+ /**
+ * Pending-request object from {@link BibleReaderNavigation}. Pass one object
+ * to one Reader. Call `request` or `focusReference` before or after mount.
+ * One pending request; a newer call replaces an older one; this reader
+ * consumes it once.
+ *
+ * Loads book / chapter / versionId only. Verse scroll and focus stay stored
+ * on the request for a later release.
+ */
+ navigation?: BibleReaderNavigation
}
export function BibleReader({
@@ -227,6 +239,7 @@ export function BibleReader({
foregroundColor,
dom,
ref,
+ navigation,
}: BibleReaderProps): ReactNode {
const context = useYouVersion()
const { lng } = useLocale()
@@ -266,7 +279,11 @@ export function BibleReader({
prop: controlledBook,
defaultProp: controlledBook !== undefined ? defaultBook : (storedBook ?? defaultBook),
onChange: (newBook) => {
- if (controlledBook === undefined && lastAppliedRef.current?.book !== newBook) {
+ if (
+ controlledBook === undefined &&
+ lastAppliedRef.current?.book !== newBook &&
+ useReaderLocationStore.getState().book !== newBook
+ ) {
setLocation({ book: newBook })
}
void onBookChange?.(newBook)
@@ -278,7 +295,11 @@ export function BibleReader({
defaultProp:
controlledChapter !== undefined ? defaultChapter : (storedChapter ?? defaultChapter),
onChange: (newChapter) => {
- if (controlledChapter === undefined && lastAppliedRef.current?.chapter !== newChapter) {
+ if (
+ controlledChapter === undefined &&
+ lastAppliedRef.current?.chapter !== newChapter &&
+ useReaderLocationStore.getState().chapter !== newChapter
+ ) {
setLocation({ chapter: newChapter })
}
void onChapterChange?.(newChapter)
@@ -290,7 +311,11 @@ export function BibleReader({
defaultProp:
controlledVersionId !== undefined ? defaultVersionId : (storedVersionId ?? defaultVersionId),
onChange: (newVersionId) => {
- if (controlledVersionId === undefined && lastAppliedRef.current?.versionId !== newVersionId) {
+ if (
+ controlledVersionId === undefined &&
+ lastAppliedRef.current?.versionId !== newVersionId &&
+ useReaderLocationStore.getState().versionId !== newVersionId
+ ) {
setLocation({ versionId: newVersionId })
}
void onVersionChange?.(newVersionId)
@@ -305,8 +330,88 @@ export function BibleReader({
lastAppliedRef.current = null
})
- const resolvedVersionId = versionId ?? DEFAULT_BIBLE_VERSION_ID
- const resolvedBook = book ?? DEFAULT_BOOK
+ const commitReaderLocationPatch = (next: LocationPatch) => {
+ lastAppliedRef.current = next
+ const storePatch: LocationPatch = {}
+ if (controlledBook === undefined && next.book !== undefined) {
+ storePatch.book = next.book
+ }
+ if (controlledChapter === undefined && next.chapter !== undefined) {
+ storePatch.chapter = next.chapter
+ }
+ if (controlledVersionId === undefined && next.versionId !== undefined) {
+ storePatch.versionId = next.versionId
+ }
+ if (
+ storePatch.book !== undefined ||
+ storePatch.chapter !== undefined ||
+ storePatch.versionId !== undefined
+ ) {
+ setLocation(storePatch)
+ }
+ }
+
+ const pendingNavigation = useConsumedNavigationRequest(navigation)
+ let appliedBook = book
+ let appliedChapter = chapter
+ let appliedVersionId = versionId
+ if (pendingNavigation) {
+ const {
+ bookId,
+ chapter: requestedChapterNumber,
+ versionId: requestedVersionId,
+ } = pendingNavigation.reference
+ const requestedBook = bookId
+ const requestedChapter = String(requestedChapterNumber)
+ if (requestedBook !== book && controlledBook === undefined) {
+ appliedBook = requestedBook
+ }
+ if (requestedChapter !== chapter && controlledChapter === undefined) {
+ appliedChapter = requestedChapter
+ }
+ if (requestedVersionId !== versionId && controlledVersionId === undefined) {
+ appliedVersionId = requestedVersionId
+ }
+ }
+
+ // Overlay paints the jump on this render. Persist one location after commit
+ // so a discarded render cannot notify the host or save a mixed book/chapter.
+ useLayoutEffect(() => {
+ if (!pendingNavigation) {
+ return
+ }
+ const {
+ bookId,
+ chapter: requestedChapterNumber,
+ versionId: requestedVersionId,
+ } = pendingNavigation.reference
+ const requestedBook = bookId
+ const requestedChapter = String(requestedChapterNumber)
+ if (
+ requestedBook === book &&
+ requestedChapter === chapter &&
+ requestedVersionId === versionId
+ ) {
+ return
+ }
+ commitReaderLocationPatch({
+ book: requestedBook,
+ chapter: requestedChapter,
+ versionId: requestedVersionId,
+ })
+ if (requestedBook !== book) {
+ setBook(requestedBook)
+ }
+ if (requestedChapter !== chapter) {
+ setChapter(requestedChapter)
+ }
+ if (requestedVersionId !== versionId) {
+ setVersionId(requestedVersionId)
+ }
+ }, [pendingNavigation, book, chapter, versionId, setBook, setChapter, setVersionId])
+
+ const resolvedVersionId = appliedVersionId ?? DEFAULT_BIBLE_VERSION_ID
+ const resolvedBook = appliedBook ?? DEFAULT_BOOK
const showNativeToolbar = Platform.OS !== 'web' && showToolbar
const {
abbreviation: versionAbbreviation,
@@ -325,7 +430,7 @@ export function BibleReader({
enabled: showNativeToolbar,
})
const bookLabel = bookTitle ?? ''
- const resolvedChapter = chapter ?? DEFAULT_CHAPTER
+ const resolvedChapter = appliedChapter ?? DEFAULT_CHAPTER
// Intro chapters carry a non-numeric id ("INTRO"); show the catalog's title, not the id.
const chapterLabel = chapterLabelForBook(bookEntry, resolvedChapter)
const previousChapter = adjacentBookChapter(
@@ -337,24 +442,7 @@ export function BibleReader({
const nextChapter = adjacentBookChapter(bookCatalog, resolvedBook, resolvedChapter, 'next')
const applyReaderLocation = (next: LocationPatch) => {
- lastAppliedRef.current = next
- const storePatch: LocationPatch = {}
- if (controlledBook === undefined && next.book !== undefined) {
- storePatch.book = next.book
- }
- if (controlledChapter === undefined && next.chapter !== undefined) {
- storePatch.chapter = next.chapter
- }
- if (controlledVersionId === undefined && next.versionId !== undefined) {
- storePatch.versionId = next.versionId
- }
- if (
- storePatch.book !== undefined ||
- storePatch.chapter !== undefined ||
- storePatch.versionId !== undefined
- ) {
- setLocation(storePatch)
- }
+ commitReaderLocationPatch(next)
if (next.book !== undefined && next.book !== resolvedBook) {
setBook(next.book)
}
@@ -366,7 +454,11 @@ export function BibleReader({
}
}
- const highlightPermissionFlow = useHighlightPermissionFlow({ versionId, book, chapter })
+ const highlightPermissionFlow = useHighlightPermissionFlow({
+ versionId: appliedVersionId,
+ book: appliedBook,
+ chapter: appliedChapter,
+ })
const {
highlights,
scope: highlightScope,
@@ -421,7 +513,14 @@ export function BibleReader({
// The stale intent on the ref is left alone. Closing the sheet means nothing
// can fire `onConfirm`, the next swatch press overwrites the intent, and the
// confirm handler re-checks the scope anyway.
- const currentScope: HighlightScope = { versionId, book, chapter }
+ const currentScope: HighlightScope = useMemo(
+ () => ({
+ versionId: appliedVersionId,
+ book: appliedBook,
+ chapter: appliedChapter,
+ }),
+ [appliedVersionId, appliedBook, appliedChapter],
+ )
let renderedPrompt = prompt
if (prompt.kind === 'sign-in' && !sameScope(prompt.scope, currentScope)) {
renderedPrompt = NO_PROMPT
@@ -468,11 +567,11 @@ export function BibleReader({
// Backstop for the during-render discard above. A confirm that races a
// controlled location change must not hand verse numbers to the current
// location-scoped flow.
- if (!sameScope(pending.scope, { versionId, book, chapter })) return
+ if (!sameScope(pending.scope, currentScope)) return
void applyHighlight(pending.color, pending.verses).then((outcome) =>
reportHighlightWriteError(outcome, onHighlightError),
)
- }, [applyHighlight, onHighlightError, versionId, book, chapter])
+ }, [applyHighlight, onHighlightError, currentScope])
const handleSwatchPress = (swatch: VerseActionSwatch) => {
const verses = verseSelection?.verses ?? []
@@ -498,10 +597,10 @@ export function BibleReader({
pendingIntentRef.current = {
color: swatch.color,
verses,
- scope: { versionId, book, chapter },
+ scope: currentScope,
}
if (authGate === 'signed-out') {
- setPrompt({ kind: 'sign-in', scope: { versionId, book, chapter } })
+ setPrompt({ kind: 'sign-in', scope: currentScope })
}
return
}
@@ -539,7 +638,7 @@ export function BibleReader({
if (prompt.kind !== 'none') return
const pending = pendingIntentRef.current
if (pending === null) return
- if (!sameScope(pending.scope, { versionId, book, chapter })) {
+ if (!sameScope(pending.scope, currentScope)) {
pendingIntentRef.current = null
return
}
@@ -555,7 +654,7 @@ export function BibleReader({
return _exhaustive
}
}
- }, [authGate, prompt.kind, versionId, book, chapter, replayPendingIntent])
+ }, [authGate, prompt.kind, currentScope, replayPendingIntent])
const handleOpenBibleThemeSettings = () => {
setIsSettingsSheetOpen(true)
@@ -759,9 +858,9 @@ export function BibleReader({
onSignOutPress={guardedSignOut}
userInfo={userInfo}
theme={resolvedTheme}
- book={book}
- chapter={chapter}
- versionId={versionId}
+ book={appliedBook}
+ chapter={appliedChapter}
+ versionId={appliedVersionId}
fontSize={fontSize}
fontFamily={encodeFontFamilyForDom(fontFamily)}
lineSpacing={lineSpacing}
@@ -850,9 +949,9 @@ export function BibleReader({
setIsPickerOpen(false)}
- book={book}
- chapter={chapter}
- versionId={versionId}
+ book={appliedBook}
+ chapter={appliedChapter}
+ versionId={appliedVersionId}
theme={resolvedTheme}
onSelect={async (data) => {
applyReaderLocation({
@@ -867,7 +966,7 @@ export function BibleReader({
setIsVersionPickerOpen(false)}
- versionId={versionId}
+ versionId={appliedVersionId}
theme={resolvedTheme}
onSelect={async (newVersionId) => {
setVersionId(newVersionId)
diff --git a/packages/ui/src/native/index.ts b/packages/ui/src/native/index.ts
index 5e0dd402..dc37b3b1 100644
--- a/packages/ui/src/native/index.ts
+++ b/packages/ui/src/native/index.ts
@@ -10,6 +10,8 @@ export type {
BibleReaderVerseSelection,
HighlightWriteError,
} from './bible-reader'
+export { BibleReaderNavigation, createBibleReaderNavigation } from './bible-reader-navigation'
+export type { BibleReaderNavigationRequest } from './bible-reader-navigation'
export { BibleReaderSettingsSheet } from './bible-reader-settings-sheet'
export type { BibleReaderSettingsSheetProps } from './bible-reader-settings-sheet'
export { BibleTextView } from './bible-text-view'