From 61e844b0d6006a7dae03d1ce218c7d59134c179c Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 8 Sep 2026 13:44:31 -0500 Subject: [PATCH 01/12] feat(core): add placeholder Search client YPE-5706: temporary Search HTTP in expo-core until platform-core ships SearchClient. Public hook and types only; wrapper stays internal. Co-authored-by: Cursor --- .changeset/ype-5706-search-core.md | 5 + CONTEXT.md | 4 + packages/core/src/hook-overrides.ts | 2 + packages/core/src/index.ts | 18 + .../core/src/search/__tests__/api.test.ts | 364 +++++++++++++++++ .../core/src/search/__tests__/exports.test.ts | 25 ++ .../src/search/__tests__/use-search.test.tsx | 87 ++++ .../core/src/search/__tests__/usfm.test.ts | 22 ++ packages/core/src/search/api.ts | 373 ++++++++++++++++++ packages/core/src/search/index.ts | 19 + packages/core/src/search/types.ts | 80 ++++ packages/core/src/search/use-search.ts | 16 + packages/core/src/search/usfm.ts | 37 ++ 13 files changed, 1052 insertions(+) create mode 100644 .changeset/ype-5706-search-core.md create mode 100644 packages/core/src/search/__tests__/api.test.ts create mode 100644 packages/core/src/search/__tests__/exports.test.ts create mode 100644 packages/core/src/search/__tests__/use-search.test.tsx create mode 100644 packages/core/src/search/__tests__/usfm.test.ts create mode 100644 packages/core/src/search/api.ts create mode 100644 packages/core/src/search/index.ts create mode 100644 packages/core/src/search/types.ts create mode 100644 packages/core/src/search/use-search.ts create mode 100644 packages/core/src/search/usfm.ts diff --git a/.changeset/ype-5706-search-core.md b/.changeset/ype-5706-search-core.md new file mode 100644 index 00000000..efa0b829 --- /dev/null +++ b/.changeset/ype-5706-search-core.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-core': minor +--- + +feat: add placeholder Search client (YPE-5706). Public `useSearch` and USFM helper until platform-core ships SearchClient. diff --git a/CONTEXT.md b/CONTEXT.md index 1bd6018e..35e0b493 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -209,3 +209,7 @@ _Avoid_: Forwarding headers from the WebView (native is authoritative); `BibleCl **Content Read-Through**: The interception of the Web SDK's `fetch` inside an **Expo DOM Component** that hands an eligible request's URL to the **Bible Content Client** through one **Native Action** and rebuilds a `Response` from what comes back. The WebView never performs an eligible request itself. The Web SDK and its query cache are unaware of it. Throwaway by design: it exists only while content renders in a WebView. See [ADR 0020](docs/adr/0020-bible-content-cache-below-fetch.md). _Avoid_: Query persistence (the Web SDK's query client is private and its persister has no per-entry lifetime); a WebView-side network fallback; read/write cache actions (the bridge carries requests, not cache entries) + +**Search**: +Placeholder client in expo-core until `@youversion/platform-core` ships `SearchClient` (YPE-5622). Public operations: `suggestedQueries`, `trendingQueries`, `verses`, `topics`. The HTTP wrapper stays internal; replace it by wrapping the shared client the way highlights wraps `HighlightsClient`. Search is a normal JSON API, not **Bible Content Cache** / [ADR 0020](docs/adr/0020-bible-content-cache-below-fetch.md). +_Avoid_: Exporting the HTTP wrapper; treating Search as Bible Content; depending on a platform-core `SearchClient` that does not exist yet diff --git a/packages/core/src/hook-overrides.ts b/packages/core/src/hook-overrides.ts index c5f8853c..066111e0 100644 --- a/packages/core/src/hook-overrides.ts +++ b/packages/core/src/hook-overrides.ts @@ -1,6 +1,7 @@ import type { AuthContextValue } from './auth/auth-context' import type { UseHighlightPermissionFlowResult } from './highlights/use-highlight-permission-flow' import type { UseHighlightsOptions, UseHighlightsResult } from './highlights/use-highlights' +import type { UseSearchResult } from './search/use-search' /** * Test seam: skip live fetch and return stub hook results. @@ -13,4 +14,5 @@ export type HookOverrides = { useYVAuth?: AuthContextValue | null useHighlights?: (options: UseHighlightsOptions) => UseHighlightsResult useHighlightPermissionFlow?: (options: UseHighlightsOptions) => UseHighlightPermissionFlowResult + useSearch?: () => UseSearchResult } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a458981..f7e0992c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,3 +46,21 @@ export { mmkvStorage } from './storage' export type { BibleContentRequest, BibleContentResponse, FetchBibleContent } from './bible-content/client' +export { bibleReferenceFromUsfm, SEARCH_USER_INTENT, useSearch } from './search' +export type { + BibleReference, + SearchApiError, + SearchApiResult, + SearchTopicsParams, + SearchVersesParams, + SuggestedQueriesParams, + TrendingQueriesParams, + UseSearchResult, + YouVersionSearchQuery, + YouVersionSearchTopic, + YouVersionSearchUserIntent, + YouVersionTopicSearchResults, + YouVersionVerseSearchResult, + YouVersionVerseSearchResults, +} from './search' + diff --git a/packages/core/src/search/__tests__/api.test.ts b/packages/core/src/search/__tests__/api.test.ts new file mode 100644 index 00000000..d7d41e9c --- /dev/null +++ b/packages/core/src/search/__tests__/api.test.ts @@ -0,0 +1,364 @@ +import { getSdkHeaders } from '../../sdk-version' +import { createSearchApi } from '../api' +import { SEARCH_USER_INTENT } from '../types' + +const mockFetch: jest.MockedFunction = jest.fn() + +beforeEach(() => { + mockFetch.mockReset() + global.fetch = mockFetch +}) + +type SearchQueryRecord = { + text: string + source?: string | null +} + +type SearchQueriesJson = { + data: SearchQueryRecord[] +} + +type VerseRecord = { + reference?: string + unexpected?: boolean +} + +type VersesJson = { + verses: VerseRecord[] + user_intent?: string | null + did_you_mean?: string[] + search_instead_for?: string | null + next_page_token?: string | null +} + +type TopicRecord = { + id?: number | null + text: string + subtopics: string[] +} + +type TopicsJson = { + topics: TopicRecord[] + did_you_mean: string[] + search_instead_for?: string | null + total_size: number +} + +type SearchJson = SearchQueriesJson | VersesJson | TopicsJson + +type LastRequest = { + url: URL + init: RequestInit +} + +function jsonResponse(body: SearchJson, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + statusText: String(status), + headers: { 'content-type': 'application/json' }, + }) +} + +function errorResponse(status: number): Response { + return new Response('', { + status, + statusText: String(status), + }) +} + +function header(init: RequestInit, name: string): string | null { + return new Headers(init.headers).get(name) +} + +function lastRequest(): LastRequest { + const call = mockFetch.mock.calls[0] + if (call === undefined) { + throw new Error('expected fetch to have been called') + } + return { url: new URL(String(call[0])), init: call[1] ?? {} } +} + +const api = () => + createSearchApi({ + appKey: 'appkey', + apiHost: 'api.example.com', + installationId: 'inst-1', + }) + +describe('createSearchApi', () => { + describe('suggestedQueries', () => { + it('GETs search-queries with query, language ranges, and YVP headers', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + data: [ + { text: 'whom shall I fear', source: 'community' }, + { text: 'whom have I in heaven', source: null }, + ], + }), + ) + + const result = await api().suggestedQueries({ + query: 'whom', + languageRanges: ['en-US', 'es'], + }) + + expect(result).toEqual({ + ok: true, + value: [ + { text: 'whom shall I fear', source: 'community' }, + { text: 'whom have I in heaven' }, + ], + }) + + const { url, init } = lastRequest() + expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-queries') + expect(url.searchParams.get('query')).toBe('whom') + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-US', 'es']) + expect(url.searchParams.has('trending')).toBe(false) + expect(init.method).toBe('GET') + expect(header(init, 'X-YVP-App-Key')).toBe('appkey') + expect(header(init, 'X-YVP-Installation-Id')).toBe('inst-1') + expect(header(init, 'X-YVP-Sdk')).toBe(getSdkHeaders()['X-YVP-Sdk']) + }) + + it('returns an empty list on 204 without treating it as an error', async () => { + mockFetch.mockResolvedValue(new Response(null, { status: 204, statusText: 'No Content' })) + + const result = await api().suggestedQueries({ query: 'love', languageRanges: ['en'] }) + + expect(result).toEqual({ ok: true, value: [] }) + }) + + it('rejects an empty query before fetch', async () => { + const result = await api().suggestedQueries({ query: '', languageRanges: ['en'] }) + + expect(result).toEqual({ + ok: false, + error: { kind: 'invalid-parameter', message: 'Invalid search parameter' }, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects empty or invalid language ranges before fetch', async () => { + const empty = await api().suggestedQueries({ query: 'love', languageRanges: [] }) + expect(empty.ok).toBe(false) + if (empty.ok) return + expect(empty.error.kind).toBe('invalid-parameter') + + const blank = await api().suggestedQueries({ query: 'love', languageRanges: [''] }) + expect(blank.ok).toBe(false) + if (blank.ok) return + expect(blank.error.kind).toBe('invalid-parameter') + + const bad = await api().suggestedQueries({ query: 'love', languageRanges: ['en--US'] }) + expect(bad.ok).toBe(false) + if (bad.ok) return + expect(bad.error.kind).toBe('invalid-parameter') + + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + describe('trendingQueries', () => { + it('GETs search-queries with trending=true and no query', async () => { + mockFetch.mockResolvedValue(jsonResponse({ data: [{ text: 'love', source: 'trending' }] })) + + const result = await api().trendingQueries({ languageRanges: ['en'] }) + + expect(result).toEqual({ + ok: true, + value: [{ text: 'love', source: 'trending' }], + }) + + const { url } = lastRequest() + expect(url.pathname).toBe('/v1-beta/search-queries') + expect(url.searchParams.get('trending')).toBe('true') + expect(url.searchParams.has('query')).toBe(false) + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en']) + }) + + it('returns an empty list on 204', async () => { + mockFetch.mockResolvedValue(new Response(null, { status: 204, statusText: 'No Content' })) + + const result = await api().trendingQueries({ languageRanges: ['*'] }) + + expect(result).toEqual({ ok: true, value: [] }) + }) + }) + + describe('verses', () => { + it('GETs search-verses and maps snake_case JSON', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + verses: [{ reference: 'MAT.14.17' }, { reference: 'JHN.6.9' }], + user_intent: 'text', + did_you_mean: ['two fishes'], + search_instead_for: null, + next_page_token: 'next-token', + }), + ) + + const result = await api().verses({ + query: 'two fish', + bibleId: 111, + userIntent: SEARCH_USER_INTENT.text, + pageSize: 25, + pageToken: 'current-token', + }) + + expect(result).toEqual({ + ok: true, + value: { + verses: [{ reference: 'MAT.14.17' }, { reference: 'JHN.6.9' }], + userIntent: 'text', + didYouMean: ['two fishes'], + searchInsteadFor: undefined, + nextPageToken: 'next-token', + }, + }) + + const { url, init } = lastRequest() + expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-verses') + expect(url.searchParams.get('query')).toBe('two fish') + expect(url.searchParams.get('bible_id')).toBe('111') + expect(url.searchParams.get('user_intent')).toBe('text') + expect(url.searchParams.get('page_size')).toBe('25') + expect(url.searchParams.get('page_token')).toBe('current-token') + expect(header(init, 'X-YVP-App-Key')).toBe('appkey') + expect(header(init, 'X-YVP-Installation-Id')).toBe('inst-1') + expect(header(init, 'X-YVP-Sdk')).toBe(getSdkHeaders()['X-YVP-Sdk']) + }) + + it('defaults user_intent to unknown and preserves future server values', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + verses: [], + user_intent: 'future-intent', + did_you_mean: [], + search_instead_for: null, + }), + ) + + const result = await api().verses({ query: 'love', bibleId: 111 }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value.userIntent).toBe('future-intent') + + const { url } = lastRequest() + expect(url.searchParams.get('user_intent')).toBe(SEARCH_USER_INTENT.unknown) + }) + + it('rejects invalid query length, bibleId, and pageSize before fetch', async () => { + const cases = [ + { query: '', bibleId: 111 }, + { query: 'a'.repeat(101), bibleId: 111 }, + { query: 'love', bibleId: 0 }, + { query: 'love', bibleId: -1 }, + { query: 'love', bibleId: 2_147_483_648 }, + { query: 'love', bibleId: 111, pageSize: 0 }, + { query: 'love', bibleId: 111, pageSize: 100 }, + ] + + for (const params of cases) { + const result = await api().verses(params) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe('invalid-parameter') + } + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns auth failure for 401 and 403 without throwing', async () => { + mockFetch.mockResolvedValue(errorResponse(401)) + const unauthorized = await api().verses({ query: 'love', bibleId: 111 }) + expect(unauthorized.ok).toBe(false) + if (unauthorized.ok) return + expect(unauthorized.error).toMatchObject({ kind: 'auth', status: 401 }) + + mockFetch.mockResolvedValue(errorResponse(403)) + const forbidden = await api().verses({ query: 'love', bibleId: 111 }) + expect(forbidden.ok).toBe(false) + if (forbidden.ok) return + expect(forbidden.error).toMatchObject({ kind: 'auth', status: 403 }) + }) + + it('returns transient failure for 5xx, bad JSON, and network errors', async () => { + mockFetch.mockResolvedValue(errorResponse(500)) + const serverError = await api().verses({ query: 'love', bibleId: 111 }) + expect(serverError.ok).toBe(false) + if (serverError.ok) return + expect(serverError.error).toMatchObject({ kind: 'transient', status: 500 }) + + mockFetch.mockResolvedValue(jsonResponse({ verses: [{ unexpected: true }] })) + const badPayload = await api().verses({ query: 'love', bibleId: 111 }) + expect(badPayload.ok).toBe(false) + if (badPayload.ok) return + expect(badPayload.error.kind).toBe('transient') + expect(badPayload.error.message).toMatch(/Unexpected search API response/) + + mockFetch.mockRejectedValue(new TypeError('Network request failed')) + const networkError = await api().verses({ query: 'love', bibleId: 111 }) + expect(networkError.ok).toBe(false) + if (networkError.ok) return + expect(networkError.error.kind).toBe('transient') + if (networkError.error.kind !== 'transient') return + expect(networkError.error.status).toBeUndefined() + }) + }) + + describe('topics', () => { + it('GETs search-topics and maps snake_case JSON', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + topics: [ + { id: 42, text: 'Faith', subtopics: ['trust', 'belief'] }, + { id: null, text: 'Love', subtopics: [] }, + ], + did_you_mean: ['faith'], + search_instead_for: 'faith', + total_size: 2, + }), + ) + + const result = await api().topics({ query: 'faif', languageRanges: ['en-US', '*'] }) + + expect(result).toEqual({ + ok: true, + value: { + topics: [ + { id: 42, text: 'Faith', subtopics: ['trust', 'belief'] }, + { text: 'Love', subtopics: [] }, + ], + didYouMean: ['faith'], + searchInsteadFor: 'faith', + totalSize: 2, + }, + }) + + const { url } = lastRequest() + expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-topics') + expect(url.searchParams.get('query')).toBe('faif') + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-US', '*']) + }) + + it('rejects empty, overlong, or badly ranged topic searches before fetch', async () => { + const emptyQuery = await api().topics({ query: '', languageRanges: ['en'] }) + expect(emptyQuery.ok).toBe(false) + if (emptyQuery.ok) return + expect(emptyQuery.error.kind).toBe('invalid-parameter') + + const longQuery = await api().topics({ query: 'a'.repeat(101), languageRanges: ['en'] }) + expect(longQuery.ok).toBe(false) + if (longQuery.ok) return + expect(longQuery.error.kind).toBe('invalid-parameter') + + const emptyRanges = await api().topics({ query: 'love', languageRanges: [] }) + expect(emptyRanges.ok).toBe(false) + if (emptyRanges.ok) return + expect(emptyRanges.error.kind).toBe('invalid-parameter') + + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/core/src/search/__tests__/exports.test.ts b/packages/core/src/search/__tests__/exports.test.ts new file mode 100644 index 00000000..e63fbd0e --- /dev/null +++ b/packages/core/src/search/__tests__/exports.test.ts @@ -0,0 +1,25 @@ +/** + * Guards the Search public surface: `useSearch` and the USFM helper are on the + * package index; the temporary HTTP wrapper and the Result seam stay internal. + */ +import * as core from '../../index' + +describe('search package exports', () => { + it('exposes the search hook and the USFM helper', () => { + expect(core.useSearch).toEqual(expect.any(Function)) + expect(core.bibleReferenceFromUsfm).toEqual(expect.any(Function)) + expect(core.SEARCH_USER_INTENT).toEqual({ + reference: 'reference', + text: 'text', + topical: 'topical', + unknown: 'unknown', + }) + }) + + it('keeps the HTTP wrapper and the Result seam internal', () => { + const names = Object.keys(core) + expect(names).not.toContain('createSearchApi') + expect(names).not.toContain('ok') + expect(names).not.toContain('err') + }) +}) diff --git a/packages/core/src/search/__tests__/use-search.test.tsx b/packages/core/src/search/__tests__/use-search.test.tsx new file mode 100644 index 00000000..cf4b32ee --- /dev/null +++ b/packages/core/src/search/__tests__/use-search.test.tsx @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react-native' +import type { ReactNode } from 'react' + +import type { HookOverrides } from '../../hook-overrides' +import { YouVersionContext } from '../../youversion-context' +import type { UseSearchResult } from '../use-search' +import { useSearch } from '../use-search' + +const mockFetch: jest.MockedFunction = jest.fn() + +beforeEach(() => { + mockFetch.mockReset() + global.fetch = mockFetch +}) + +function Wrapper({ + children, + hookOverrides, +}: { + children: ReactNode + hookOverrides?: HookOverrides +}) { + return ( + + {children} + + ) +} + +describe('useSearch', () => { + it('reads provider config and returns the four operations', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ data: [{ text: 'love', source: 'trending' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + + const { result } = renderHook(() => useSearch(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current.suggestedQueries).toEqual(expect.any(Function)) + expect(result.current.trendingQueries).toEqual(expect.any(Function)) + expect(result.current.verses).toEqual(expect.any(Function)) + expect(result.current.topics).toEqual(expect.any(Function)) + + const trending = await result.current.trendingQueries({ languageRanges: ['en'] }) + expect(trending).toEqual({ + ok: true, + value: [{ text: 'love', source: 'trending' }], + }) + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('https://api.example.com/v1-beta/search-queries') + }) + + it('returns the hookOverrides stub instead of calling fetch', async () => { + const stub: UseSearchResult = { + suggestedQueries: jest.fn(async () => ({ ok: true as const, value: [{ text: 'stub' }] })), + trendingQueries: jest.fn(async () => ({ ok: true as const, value: [] })), + verses: jest.fn(async () => ({ + ok: true as const, + value: { verses: [], didYouMean: [] }, + })), + topics: jest.fn(async () => ({ + ok: true as const, + value: { topics: [], didYouMean: [], totalSize: 0 }, + })), + } + + const { result } = renderHook(() => useSearch(), { + wrapper: ({ children }) => stub }}>{children}, + }) + + expect(result.current).toBe(stub) + const suggested = await result.current.suggestedQueries({ query: 'love', languageRanges: ['en'] }) + expect(suggested).toEqual({ ok: true, value: [{ text: 'stub' }] }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/search/__tests__/usfm.test.ts b/packages/core/src/search/__tests__/usfm.test.ts new file mode 100644 index 00000000..ea4e0508 --- /dev/null +++ b/packages/core/src/search/__tests__/usfm.test.ts @@ -0,0 +1,22 @@ +import { bibleReferenceFromUsfm } from '../usfm' + +describe('bibleReferenceFromUsfm', () => { + it('parses BOOK.CHAPTER.VERSE into a reference', () => { + expect(bibleReferenceFromUsfm('JHN.3.16', 111)).toEqual({ + versionId: 111, + bookId: 'JHN', + chapter: 3, + verse: 16, + }) + }) + + it('returns null for invalid USFM', () => { + expect(bibleReferenceFromUsfm('JHN.3', 111)).toBeNull() + expect(bibleReferenceFromUsfm('JHN.3.16.1', 111)).toBeNull() + expect(bibleReferenceFromUsfm('.3.16', 111)).toBeNull() + expect(bibleReferenceFromUsfm('JHN.0.16', 111)).toBeNull() + expect(bibleReferenceFromUsfm('JHN.3.0', 111)).toBeNull() + expect(bibleReferenceFromUsfm('JHN.3.16-18', 111)).toBeNull() + expect(bibleReferenceFromUsfm('', 111)).toBeNull() + }) +}) diff --git a/packages/core/src/search/api.ts b/packages/core/src/search/api.ts new file mode 100644 index 00000000..eca57923 --- /dev/null +++ b/packages/core/src/search/api.ts @@ -0,0 +1,373 @@ +import { z } from 'zod' + +import { DEFAULT_API_HOST } from '../constants' +import { toMessage } from '../error-message' +import { err, ok } from '../result' +import { mergeSdkHeaders } from '../sdk-version' +import { + SEARCH_USER_INTENT, + type SearchApiError, + type SearchApiResult, + type SearchTopicsParams, + type SearchVersesParams, + type SuggestedQueriesParams, + type TrendingQueriesParams, + type YouVersionSearchQuery, + type YouVersionSearchTopic, + type YouVersionTopicSearchResults, + type YouVersionVerseSearchResult, + type YouVersionVerseSearchResults, +} from './types' + +export type CreateSearchApiConfig = { + appKey: string + installationId: string + apiHost?: string + additionalHeaders?: Record + /** Matches the Bible content client's 10s. RN's OkHttp ships with no timeouts. */ + timeoutMs?: number +} + +export type SearchApi = { + suggestedQueries: ( + params: SuggestedQueriesParams, + ) => Promise> + trendingQueries: ( + params: TrendingQueriesParams, + ) => Promise> + verses: (params: SearchVersesParams) => Promise> + topics: (params: SearchTopicsParams) => Promise> +} + +const DEFAULT_TIMEOUT_MS = 10_000 +const INT32_MAX = 2_147_483_647 +const LANGUAGE_RANGE = /^[A-z]{1,8}([-_][0-9A-z]{1,8})*$/ +const INVALID_PARAMETER: SearchApiError = { + kind: 'invalid-parameter', + message: 'Invalid search parameter', +} + +const queryRowSchema = z.object({ + text: z.string(), + source: z.string().nullish(), +}) + +const queriesResponseSchema = z.object({ + data: z.array(queryRowSchema), +}) + +const verseRowSchema = z.object({ + reference: z.string(), +}) + +const versesResponseSchema = z.object({ + verses: z.array(verseRowSchema), + user_intent: z.string().nullish(), + did_you_mean: z.array(z.string()), + search_instead_for: z.string().nullish(), + next_page_token: z.string().nullish(), +}) + +const topicRowSchema = z.object({ + id: z.number().int().nullish(), + text: z.string(), + subtopics: z.array(z.string()), +}) + +const topicsResponseSchema = z.object({ + topics: z.array(topicRowSchema), + did_you_mean: z.array(z.string()), + search_instead_for: z.string().nullish(), + total_size: z.number().int(), +}) + +/** + * Temporary Search HTTP client until `@youversion/platform-core` ships + * `SearchClient` (YPE-5622). Delete this fetch layer and wrap the shared + * client the way `createHighlightsApi` wraps `HighlightsClient`. + */ +export function createSearchApi(config: CreateSearchApiConfig): SearchApi { + const apiHost = config.apiHost ?? DEFAULT_API_HOST + const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS + const headers = { + 'X-YVP-App-Key': config.appKey, + 'X-YVP-Installation-Id': config.installationId, + ...mergeSdkHeaders(config.additionalHeaders), + } + + return { + suggestedQueries(params) { + if (params.query === '') { + return Promise.resolve(err(INVALID_PARAMETER)) + } + return fetchQueries({ + apiHost, + headers, + timeoutMs, + languageRanges: params.languageRanges, + query: params.query, + trending: false, + }) + }, + trendingQueries(params) { + return fetchQueries({ + apiHost, + headers, + timeoutMs, + languageRanges: params.languageRanges, + query: undefined, + trending: true, + }) + }, + verses(params) { + return fetchVerses({ apiHost, headers, timeoutMs, params }) + }, + topics(params) { + return fetchTopics({ apiHost, headers, timeoutMs, params }) + }, + } +} + +async function fetchQueries({ + apiHost, + headers, + timeoutMs, + languageRanges, + query, + trending, +}: { + apiHost: string + headers: Record + timeoutMs: number + languageRanges: string[] + query: string | undefined + trending: boolean +}): Promise> { + if (!isValidLanguageRanges(languageRanges)) { + return err(INVALID_PARAMETER) + } + + const pairs: [string, string][] = languageRanges.map((range) => ['language_ranges[]', range]) + if (query !== undefined) { + pairs.push(['query', query]) + } + if (trending) { + pairs.push(['trending', 'true']) + } + + const response = await sendGet(apiHost, '/v1-beta/search-queries', pairs, headers, timeoutMs) + if (!response.ok) { + return response + } + if (response.value.status === 204) { + return ok([]) + } + const parsed = await parseJson(response.value, queriesResponseSchema) + if (!parsed.ok) { + return parsed + } + return ok(parsed.value.data.map(mapQuery)) +} + +async function fetchVerses({ + apiHost, + headers, + timeoutMs, + params, +}: { + apiHost: string + headers: Record + timeoutMs: number + params: SearchVersesParams +}): Promise> { + const { query, bibleId, pageSize, pageToken } = params + const userIntent = params.userIntent ?? SEARCH_USER_INTENT.unknown + if (!isValidQueryLength(query) || !isValidBibleId(bibleId) || !isValidPageSize(pageSize)) { + return err(INVALID_PARAMETER) + } + + const pairs: [string, string][] = [ + ['query', query], + ['bible_id', String(bibleId)], + ['user_intent', userIntent], + ] + if (pageSize !== undefined) { + pairs.push(['page_size', String(pageSize)]) + } + if (pageToken !== undefined) { + pairs.push(['page_token', pageToken]) + } + + const response = await sendGet(apiHost, '/v1-beta/search-verses', pairs, headers, timeoutMs) + if (!response.ok) { + return response + } + const parsed = await parseJson(response.value, versesResponseSchema) + if (!parsed.ok) { + return parsed + } + const body = parsed.value + return ok({ + verses: body.verses.map(mapVerse), + userIntent: body.user_intent ?? undefined, + didYouMean: body.did_you_mean, + searchInsteadFor: body.search_instead_for ?? undefined, + nextPageToken: body.next_page_token ?? undefined, + }) +} + +async function fetchTopics({ + apiHost, + headers, + timeoutMs, + params, +}: { + apiHost: string + headers: Record + timeoutMs: number + params: SearchTopicsParams +}): Promise> { + const { query, languageRanges } = params + if (!isValidQueryLength(query) || !isValidLanguageRanges(languageRanges)) { + return err(INVALID_PARAMETER) + } + + const pairs: [string, string][] = [ + ['query', query], + ...languageRanges.map((range): [string, string] => ['language_ranges[]', range]), + ] + + const response = await sendGet(apiHost, '/v1-beta/search-topics', pairs, headers, timeoutMs) + if (!response.ok) { + return response + } + const parsed = await parseJson(response.value, topicsResponseSchema) + if (!parsed.ok) { + return parsed + } + const body = parsed.value + return ok({ + topics: body.topics.map(mapTopic), + didYouMean: body.did_you_mean, + searchInsteadFor: body.search_instead_for ?? undefined, + totalSize: body.total_size, + }) +} + +type HttpOk = { status: number; response: Response } + +async function sendGet( + apiHost: string, + path: string, + pairs: [string, string][], + headers: Record, + timeoutMs: number, +): Promise> { + const url = buildUrl(apiHost, path, pairs) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + method: 'GET', + headers, + signal: controller.signal, + }) + const { status } = response + if (status === 401 || status === 403) { + return err({ + kind: 'auth', + status, + message: statusText(status), + }) + } + if (status === 204) { + return ok({ status, response }) + } + if (!response.ok) { + return err({ + kind: 'transient', + status, + message: statusText(status), + }) + } + return ok({ status, response }) + } catch (caught) { + const error = caught instanceof Error ? caught : new Error(String(caught)) + return err({ kind: 'transient', message: toMessage(error) }) + } finally { + clearTimeout(timer) + } +} + +async function parseJson( + http: HttpOk, + schema: z.ZodType, +): Promise> { + try { + const json: unknown = await http.response.json() + const parsed = schema.safeParse(json) + if (!parsed.success) { + return err({ kind: 'transient', message: 'Unexpected search API response' }) + } + return ok(parsed.data) + } catch (caught) { + const error = caught instanceof Error ? caught : new Error(String(caught)) + return err({ kind: 'transient', message: toMessage(error) }) + } +} + +function buildUrl(apiHost: string, path: string, pairs: [string, string][]): string { + const url = new URL(`https://${apiHost}${path}`) + for (const [key, value] of pairs) { + url.searchParams.append(key, value) + } + return url.toString() +} + +function isValidQueryLength(query: string): boolean { + return query.length >= 1 && query.length <= 100 +} + +function isValidBibleId(bibleId: number): boolean { + return Number.isInteger(bibleId) && bibleId > 0 && bibleId <= INT32_MAX +} + +function isValidPageSize(pageSize: number | undefined): boolean { + if (pageSize === undefined) { + return true + } + return Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 99 +} + +function isValidLanguageRanges(languageRanges: string[]): boolean { + if (languageRanges.length === 0) { + return false + } + return languageRanges.every(isValidLanguageRange) +} + +function isValidLanguageRange(languageRange: string): boolean { + return languageRange === '*' || LANGUAGE_RANGE.test(languageRange) +} + +function mapQuery(row: z.infer): YouVersionSearchQuery { + if (row.source == null) { + return { text: row.text } + } + return { text: row.text, source: row.source } +} + +function mapVerse(row: z.infer): YouVersionVerseSearchResult { + return { reference: row.reference } +} + +function mapTopic(row: z.infer): YouVersionSearchTopic { + if (row.id == null) { + return { text: row.text, subtopics: row.subtopics } + } + return { id: row.id, text: row.text, subtopics: row.subtopics } +} + +function statusText(status: number): string { + return `Request failed with status ${status}` +} diff --git a/packages/core/src/search/index.ts b/packages/core/src/search/index.ts new file mode 100644 index 00000000..95380a68 --- /dev/null +++ b/packages/core/src/search/index.ts @@ -0,0 +1,19 @@ +export { createSearchApi, type CreateSearchApiConfig, type SearchApi } from './api' +export { + SEARCH_USER_INTENT, + type BibleReference, + type SearchApiError, + type SearchApiResult, + type SearchTopicsParams, + type SearchVersesParams, + type SuggestedQueriesParams, + type TrendingQueriesParams, + type YouVersionSearchQuery, + type YouVersionSearchTopic, + type YouVersionSearchUserIntent, + type YouVersionTopicSearchResults, + type YouVersionVerseSearchResult, + type YouVersionVerseSearchResults, +} from './types' +export { bibleReferenceFromUsfm } from './usfm' +export { useSearch, type UseSearchResult } from './use-search' diff --git a/packages/core/src/search/types.ts b/packages/core/src/search/types.ts new file mode 100644 index 00000000..e48f486a --- /dev/null +++ b/packages/core/src/search/types.ts @@ -0,0 +1,80 @@ +import type { Result } from '../result' + +/** + * Server-supplied search intent. A string wrapper, not an enum: known + * constants live on {@link SEARCH_USER_INTENT}, and unknown values are kept. + */ +export type YouVersionSearchUserIntent = string + +export const SEARCH_USER_INTENT = { + reference: 'reference', + text: 'text', + topical: 'topical', + unknown: 'unknown', +} as const + +export type YouVersionSearchQuery = { + text: string + source?: string +} + +export type YouVersionVerseSearchResult = { + reference: string +} + +export type YouVersionVerseSearchResults = { + verses: YouVersionVerseSearchResult[] + userIntent?: YouVersionSearchUserIntent + didYouMean: string[] + searchInsteadFor?: string + nextPageToken?: string +} + +export type YouVersionSearchTopic = { + id?: number + text: string + subtopics: string[] +} + +export type YouVersionTopicSearchResults = { + topics: YouVersionSearchTopic[] + didYouMean: string[] + searchInsteadFor?: string + totalSize: number +} + +export type SearchApiError = + | { kind: 'auth'; status: 401 | 403; message: string } + | { kind: 'transient'; status?: number; message: string } + | { kind: 'invalid-parameter'; message: string } + +export type SearchApiResult = Result + +export type SuggestedQueriesParams = { + query: string + languageRanges: string[] +} + +export type TrendingQueriesParams = { + languageRanges: string[] +} + +export type SearchVersesParams = { + query: string + bibleId: number + userIntent?: YouVersionSearchUserIntent + pageSize?: number + pageToken?: string +} + +export type SearchTopicsParams = { + query: string + languageRanges: string[] +} + +export type BibleReference = { + versionId: number + bookId: string + chapter: number + verse: number +} diff --git a/packages/core/src/search/use-search.ts b/packages/core/src/search/use-search.ts new file mode 100644 index 00000000..fdeb816f --- /dev/null +++ b/packages/core/src/search/use-search.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' + +import { useYouVersion } from '../use-youversion' +import { createSearchApi, type SearchApi } from './api' + +export type UseSearchResult = SearchApi + +export function useSearch(): UseSearchResult { + const { appKey, apiHost, installationId, hookOverrides } = useYouVersion() + const override = hookOverrides?.useSearch + const api = useMemo( + () => createSearchApi({ appKey, apiHost, installationId }), + [appKey, apiHost, installationId], + ) + return override?.() ?? api +} diff --git a/packages/core/src/search/usfm.ts b/packages/core/src/search/usfm.ts new file mode 100644 index 00000000..d60fc85a --- /dev/null +++ b/packages/core/src/search/usfm.ts @@ -0,0 +1,37 @@ +import type { BibleReference } from './types' + +/** + * Parses a verse USFM (`BOOK.CHAPTER.VERSE`) into a Bible reference. + * Invalid input — wrong segment count, empty book, or non-positive chapter / + * verse — returns `null`. Reader will drop those later. + */ +export function bibleReferenceFromUsfm(usfm: string, versionId: number): BibleReference | null { + const parts = usfm.split('.') + if (parts.length !== 3) { + return null + } + const [bookId, chapterPart, versePart] = parts + if (bookId === undefined || chapterPart === undefined || versePart === undefined) { + return null + } + if (bookId === '') { + return null + } + const chapter = parsePositiveInt(chapterPart) + const verse = parsePositiveInt(versePart) + if (chapter === null || verse === null) { + return null + } + return { versionId, bookId, chapter, verse } +} + +function parsePositiveInt(value: string): number | null { + if (!/^\d+$/.test(value)) { + return null + } + const parsed = Number.parseInt(value, 10) + if (parsed < 1) { + return null + } + return parsed +} From 0fb10988e3ccb6a6c04b7c592f76d1528ffcc2a9 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 9 Sep 2026 14:22:31 -0500 Subject: [PATCH 02/12] test(core): cover useSearch outside provider YPE-5706: hook must throw when used without YouVersionProvider. Co-authored-by: Cursor --- packages/core/src/search/__tests__/use-search.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/search/__tests__/use-search.test.tsx b/packages/core/src/search/__tests__/use-search.test.tsx index cf4b32ee..7bc37c1f 100644 --- a/packages/core/src/search/__tests__/use-search.test.tsx +++ b/packages/core/src/search/__tests__/use-search.test.tsx @@ -36,6 +36,12 @@ function Wrapper({ } describe('useSearch', () => { + it('throws when used outside YouVersionProvider', () => { + expect(() => renderHook(() => useSearch())).toThrow( + /useYouVersion must be used inside of YouVersionProvider/, + ) + }) + it('reads provider config and returns the four operations', async () => { mockFetch.mockResolvedValue( new Response(JSON.stringify({ data: [{ text: 'love', source: 'trending' }] }), { From 81bb3f6cf06f563b6bf7ae44e0cf97472b2099e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 14:14:10 +0000 Subject: [PATCH 03/12] feat(core): wrap platform-core SearchClient (YPE-5746) Delete the local Search HTTP client and wrap SearchClient from @youversion/platform-core 2.13.0. Thrown errors map to the existing RN Result kinds. Verse hits use id. Query lists are { queries }. Co-authored-by: Dustin Kelley --- .changeset/ype-5706-search-core.md | 2 +- CONTEXT.md | 4 +- packages/core/package.json | 2 +- .../core/src/highlights/__tests__/api.test.ts | 2 +- packages/core/src/index.ts | 1 + .../core/src/search/__tests__/api.test.ts | 69 ++-- .../core/src/search/__tests__/exports.test.ts | 4 +- .../src/search/__tests__/use-search.test.tsx | 13 +- packages/core/src/search/api.ts | 382 +++--------------- packages/core/src/search/index.ts | 1 + packages/core/src/search/types.ts | 44 +- packages/ui/package.json | 2 +- pnpm-lock.yaml | 114 +++++- 13 files changed, 215 insertions(+), 425 deletions(-) diff --git a/.changeset/ype-5706-search-core.md b/.changeset/ype-5706-search-core.md index efa0b829..5fea466e 100644 --- a/.changeset/ype-5706-search-core.md +++ b/.changeset/ype-5706-search-core.md @@ -2,4 +2,4 @@ '@youversion/platform-react-native-expo-core': minor --- -feat: add placeholder Search client (YPE-5706). Public `useSearch` and USFM helper until platform-core ships SearchClient. +feat: wrap platform-core SearchClient (YPE-5746). Public `useSearch` and USFM helper; verse hits use `id`. diff --git a/CONTEXT.md b/CONTEXT.md index 35e0b493..c6c03ae1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -211,5 +211,5 @@ The interception of the Web SDK's `fetch` inside an **Expo DOM Component** that _Avoid_: Query persistence (the Web SDK's query client is private and its persister has no per-entry lifetime); a WebView-side network fallback; read/write cache actions (the bridge carries requests, not cache entries) **Search**: -Placeholder client in expo-core until `@youversion/platform-core` ships `SearchClient` (YPE-5622). Public operations: `suggestedQueries`, `trendingQueries`, `verses`, `topics`. The HTTP wrapper stays internal; replace it by wrapping the shared client the way highlights wraps `HighlightsClient`. Search is a normal JSON API, not **Bible Content Cache** / [ADR 0020](docs/adr/0020-bible-content-cache-below-fetch.md). -_Avoid_: Exporting the HTTP wrapper; treating Search as Bible Content; depending on a platform-core `SearchClient` that does not exist yet +`createSearchApi` wraps `@youversion/platform-core` `SearchClient` the way highlights wraps `HighlightsClient`. Public operations: `suggestedQueries`, `trendingQueries`, `verses`, `topics`. Thrown client errors become RN `Result` (`auth` / `transient` / `invalid-parameter`). Types alias the shared Search DTOs (`id` is USFM on verse hits; query lists are `{ queries }`). The wrapper stays internal. Search is a normal JSON API, not **Bible Content Cache** / [ADR 0020](docs/adr/0020-bible-content-cache-below-fetch.md). +_Avoid_: Exporting the HTTP wrapper; treating Search as Bible Content; a parallel Search HTTP client or DTO layer diff --git a/packages/core/package.json b/packages/core/package.json index 484c3695..f10694c3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -113,7 +113,7 @@ "jest-expo": "56.0.5" }, "dependencies": { - "@youversion/platform-core": "2.9.0", + "@youversion/platform-core": "2.13.0", "zod": "4.4.3" } } diff --git a/packages/core/src/highlights/__tests__/api.test.ts b/packages/core/src/highlights/__tests__/api.test.ts index 610833db..f6d87239 100644 --- a/packages/core/src/highlights/__tests__/api.test.ts +++ b/packages/core/src/highlights/__tests__/api.test.ts @@ -118,7 +118,7 @@ describe('createHighlightsApi', () => { expect(header(init, 'Authorization')).toBe('Bearer tok') expect(header(init, 'X-YVP-App-Key')).toBe('appkey') expect(header(init, 'X-YVP-Installation-Id')).toBe('inst-1') - expect(header(init, 'x-yvp-sdk')).toBe('ReactSDK=2.9.0, ReactNativeSDK=1.0.0-dev') + expect(header(init, 'x-yvp-sdk')).toBe('ReactSDK=2.13.0, ReactNativeSDK=1.0.0-dev') }) it('returns auth failure for 401 and 403 without throwing', async () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7e0992c..f8f81216 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ export type { SuggestedQueriesParams, TrendingQueriesParams, UseSearchResult, + YouVersionSearchQueries, YouVersionSearchQuery, YouVersionSearchTopic, YouVersionSearchUserIntent, diff --git a/packages/core/src/search/__tests__/api.test.ts b/packages/core/src/search/__tests__/api.test.ts index d7d41e9c..4e0061f7 100644 --- a/packages/core/src/search/__tests__/api.test.ts +++ b/packages/core/src/search/__tests__/api.test.ts @@ -1,4 +1,3 @@ -import { getSdkHeaders } from '../../sdk-version' import { createSearchApi } from '../api' import { SEARCH_USER_INTENT } from '../types' @@ -104,42 +103,43 @@ describe('createSearchApi', () => { expect(result).toEqual({ ok: true, - value: [ - { text: 'whom shall I fear', source: 'community' }, - { text: 'whom have I in heaven' }, - ], + value: { + queries: [ + { text: 'whom shall I fear', source: 'community' }, + { text: 'whom have I in heaven' }, + ], + }, }) const { url, init } = lastRequest() - expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-queries') + expect(url.origin + url.pathname).toBe('https://api.example.com/v1/search-queries') expect(url.searchParams.get('query')).toBe('whom') - expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-US', 'es']) + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-us', 'es']) expect(url.searchParams.has('trending')).toBe(false) expect(init.method).toBe('GET') expect(header(init, 'X-YVP-App-Key')).toBe('appkey') expect(header(init, 'X-YVP-Installation-Id')).toBe('inst-1') - expect(header(init, 'X-YVP-Sdk')).toBe(getSdkHeaders()['X-YVP-Sdk']) + expect(header(init, 'X-YVP-Sdk')).toBe('ReactSDK=2.13.0') }) - it('returns an empty list on 204 without treating it as an error', async () => { + it('returns empty queries on 204 without treating it as an error', async () => { mockFetch.mockResolvedValue(new Response(null, { status: 204, statusText: 'No Content' })) const result = await api().suggestedQueries({ query: 'love', languageRanges: ['en'] }) - expect(result).toEqual({ ok: true, value: [] }) + expect(result).toEqual({ ok: true, value: { queries: [] } }) }) - it('rejects an empty query before fetch', async () => { + it('maps an empty query to invalid-parameter without fetching', async () => { const result = await api().suggestedQueries({ query: '', languageRanges: ['en'] }) - expect(result).toEqual({ - ok: false, - error: { kind: 'invalid-parameter', message: 'Invalid search parameter' }, - }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe('invalid-parameter') expect(mockFetch).not.toHaveBeenCalled() }) - it('rejects empty or invalid language ranges before fetch', async () => { + it('maps empty or invalid language ranges to invalid-parameter without fetching', async () => { const empty = await api().suggestedQueries({ query: 'love', languageRanges: [] }) expect(empty.ok).toBe(false) if (empty.ok) return @@ -167,27 +167,27 @@ describe('createSearchApi', () => { expect(result).toEqual({ ok: true, - value: [{ text: 'love', source: 'trending' }], + value: { queries: [{ text: 'love', source: 'trending' }] }, }) const { url } = lastRequest() - expect(url.pathname).toBe('/v1-beta/search-queries') + expect(url.pathname).toBe('/v1/search-queries') expect(url.searchParams.get('trending')).toBe('true') expect(url.searchParams.has('query')).toBe(false) expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en']) }) - it('returns an empty list on 204', async () => { + it('returns empty queries on 204', async () => { mockFetch.mockResolvedValue(new Response(null, { status: 204, statusText: 'No Content' })) const result = await api().trendingQueries({ languageRanges: ['*'] }) - expect(result).toEqual({ ok: true, value: [] }) + expect(result).toEqual({ ok: true, value: { queries: [] } }) }) }) describe('verses', () => { - it('GETs search-verses and maps snake_case JSON', async () => { + it('GETs search-verses and maps wire reference to id', async () => { mockFetch.mockResolvedValue( jsonResponse({ verses: [{ reference: 'MAT.14.17' }, { reference: 'JHN.6.9' }], @@ -209,16 +209,16 @@ describe('createSearchApi', () => { expect(result).toEqual({ ok: true, value: { - verses: [{ reference: 'MAT.14.17' }, { reference: 'JHN.6.9' }], + verses: [{ id: 'MAT.14.17' }, { id: 'JHN.6.9' }], userIntent: 'text', didYouMean: ['two fishes'], - searchInsteadFor: undefined, + searchInsteadFor: null, nextPageToken: 'next-token', }, }) const { url, init } = lastRequest() - expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-verses') + expect(url.origin + url.pathname).toBe('https://api.example.com/v1/search-verses') expect(url.searchParams.get('query')).toBe('two fish') expect(url.searchParams.get('bible_id')).toBe('111') expect(url.searchParams.get('user_intent')).toBe('text') @@ -226,10 +226,10 @@ describe('createSearchApi', () => { expect(url.searchParams.get('page_token')).toBe('current-token') expect(header(init, 'X-YVP-App-Key')).toBe('appkey') expect(header(init, 'X-YVP-Installation-Id')).toBe('inst-1') - expect(header(init, 'X-YVP-Sdk')).toBe(getSdkHeaders()['X-YVP-Sdk']) + expect(header(init, 'X-YVP-Sdk')).toBe('ReactSDK=2.13.0') }) - it('defaults user_intent to unknown and preserves future server values', async () => { + it('omits user_intent when unset and preserves future server values', async () => { mockFetch.mockResolvedValue( jsonResponse({ verses: [], @@ -246,16 +246,15 @@ describe('createSearchApi', () => { expect(result.value.userIntent).toBe('future-intent') const { url } = lastRequest() - expect(url.searchParams.get('user_intent')).toBe(SEARCH_USER_INTENT.unknown) + expect(url.searchParams.has('user_intent')).toBe(false) }) - it('rejects invalid query length, bibleId, and pageSize before fetch', async () => { + it('maps invalid query length, bibleId, and pageSize to invalid-parameter without fetching', async () => { const cases = [ { query: '', bibleId: 111 }, { query: 'a'.repeat(101), bibleId: 111 }, { query: 'love', bibleId: 0 }, { query: 'love', bibleId: -1 }, - { query: 'love', bibleId: 2_147_483_648 }, { query: 'love', bibleId: 111, pageSize: 0 }, { query: 'love', bibleId: 111, pageSize: 100 }, ] @@ -295,7 +294,7 @@ describe('createSearchApi', () => { expect(badPayload.ok).toBe(false) if (badPayload.ok) return expect(badPayload.error.kind).toBe('transient') - expect(badPayload.error.message).toMatch(/Unexpected search API response/) + expect(badPayload.error.message).toMatch(/Unexpected search-verses API response/) mockFetch.mockRejectedValue(new TypeError('Network request failed')) const networkError = await api().verses({ query: 'love', bibleId: 111 }) @@ -308,7 +307,7 @@ describe('createSearchApi', () => { }) describe('topics', () => { - it('GETs search-topics and maps snake_case JSON', async () => { + it('GETs search-topics and maps camelCase results', async () => { mockFetch.mockResolvedValue( jsonResponse({ topics: [ @@ -328,7 +327,7 @@ describe('createSearchApi', () => { value: { topics: [ { id: 42, text: 'Faith', subtopics: ['trust', 'belief'] }, - { text: 'Love', subtopics: [] }, + { id: null, text: 'Love', subtopics: [] }, ], didYouMean: ['faith'], searchInsteadFor: 'faith', @@ -337,12 +336,12 @@ describe('createSearchApi', () => { }) const { url } = lastRequest() - expect(url.origin + url.pathname).toBe('https://api.example.com/v1-beta/search-topics') + expect(url.origin + url.pathname).toBe('https://api.example.com/v1/search-topics') expect(url.searchParams.get('query')).toBe('faif') - expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-US', '*']) + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-us', '*']) }) - it('rejects empty, overlong, or badly ranged topic searches before fetch', async () => { + it('maps empty, overlong, or badly ranged topic searches to invalid-parameter without fetching', async () => { const emptyQuery = await api().topics({ query: '', languageRanges: ['en'] }) expect(emptyQuery.ok).toBe(false) if (emptyQuery.ok) return diff --git a/packages/core/src/search/__tests__/exports.test.ts b/packages/core/src/search/__tests__/exports.test.ts index e63fbd0e..a85b7f85 100644 --- a/packages/core/src/search/__tests__/exports.test.ts +++ b/packages/core/src/search/__tests__/exports.test.ts @@ -1,6 +1,6 @@ /** * Guards the Search public surface: `useSearch` and the USFM helper are on the - * package index; the temporary HTTP wrapper and the Result seam stay internal. + * package index; the SearchClient wrapper and the Result seam stay internal. */ import * as core from '../../index' @@ -16,7 +16,7 @@ describe('search package exports', () => { }) }) - it('keeps the HTTP wrapper and the Result seam internal', () => { + it('keeps the client wrapper and the Result seam internal', () => { const names = Object.keys(core) expect(names).not.toContain('createSearchApi') expect(names).not.toContain('ok') diff --git a/packages/core/src/search/__tests__/use-search.test.tsx b/packages/core/src/search/__tests__/use-search.test.tsx index 7bc37c1f..34d64bfd 100644 --- a/packages/core/src/search/__tests__/use-search.test.tsx +++ b/packages/core/src/search/__tests__/use-search.test.tsx @@ -62,15 +62,18 @@ describe('useSearch', () => { const trending = await result.current.trendingQueries({ languageRanges: ['en'] }) expect(trending).toEqual({ ok: true, - value: [{ text: 'love', source: 'trending' }], + value: { queries: [{ text: 'love', source: 'trending' }] }, }) - expect(String(mockFetch.mock.calls[0]?.[0])).toContain('https://api.example.com/v1-beta/search-queries') + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('https://api.example.com/v1/search-queries') }) it('returns the hookOverrides stub instead of calling fetch', async () => { const stub: UseSearchResult = { - suggestedQueries: jest.fn(async () => ({ ok: true as const, value: [{ text: 'stub' }] })), - trendingQueries: jest.fn(async () => ({ ok: true as const, value: [] })), + suggestedQueries: jest.fn(async () => ({ + ok: true as const, + value: { queries: [{ text: 'stub' }] }, + })), + trendingQueries: jest.fn(async () => ({ ok: true as const, value: { queries: [] } })), verses: jest.fn(async () => ({ ok: true as const, value: { verses: [], didYouMean: [] }, @@ -87,7 +90,7 @@ describe('useSearch', () => { expect(result.current).toBe(stub) const suggested = await result.current.suggestedQueries({ query: 'love', languageRanges: ['en'] }) - expect(suggested).toEqual({ ok: true, value: [{ text: 'stub' }] }) + expect(suggested).toEqual({ ok: true, value: { queries: [{ text: 'stub' }] } }) expect(mockFetch).not.toHaveBeenCalled() }) }) diff --git a/packages/core/src/search/api.ts b/packages/core/src/search/api.ts index eca57923..79e68c2f 100644 --- a/packages/core/src/search/api.ts +++ b/packages/core/src/search/api.ts @@ -1,22 +1,22 @@ -import { z } from 'zod' +import { + ApiClient, + SearchClient, + getHttpStatus, + type SearchQueries, + type SearchTopicsResponse, + type SearchVersesResponse, +} from '@youversion/platform-core' import { DEFAULT_API_HOST } from '../constants' import { toMessage } from '../error-message' import { err, ok } from '../result' -import { mergeSdkHeaders } from '../sdk-version' -import { - SEARCH_USER_INTENT, - type SearchApiError, - type SearchApiResult, - type SearchTopicsParams, - type SearchVersesParams, - type SuggestedQueriesParams, - type TrendingQueriesParams, - type YouVersionSearchQuery, - type YouVersionSearchTopic, - type YouVersionTopicSearchResults, - type YouVersionVerseSearchResult, - type YouVersionVerseSearchResults, +import type { + SearchApiError, + SearchApiResult, + SearchTopicsParams, + SearchVersesParams, + SuggestedQueriesParams, + TrendingQueriesParams, } from './types' export type CreateSearchApiConfig = { @@ -24,350 +24,72 @@ export type CreateSearchApiConfig = { installationId: string apiHost?: string additionalHeaders?: Record - /** Matches the Bible content client's 10s. RN's OkHttp ships with no timeouts. */ - timeoutMs?: number + timeout?: number } export type SearchApi = { suggestedQueries: ( params: SuggestedQueriesParams, - ) => Promise> + ) => Promise> trendingQueries: ( params: TrendingQueriesParams, - ) => Promise> - verses: (params: SearchVersesParams) => Promise> - topics: (params: SearchTopicsParams) => Promise> -} - -const DEFAULT_TIMEOUT_MS = 10_000 -const INT32_MAX = 2_147_483_647 -const LANGUAGE_RANGE = /^[A-z]{1,8}([-_][0-9A-z]{1,8})*$/ -const INVALID_PARAMETER: SearchApiError = { - kind: 'invalid-parameter', - message: 'Invalid search parameter', + ) => Promise> + verses: (params: SearchVersesParams) => Promise> + topics: (params: SearchTopicsParams) => Promise> } -const queryRowSchema = z.object({ - text: z.string(), - source: z.string().nullish(), -}) - -const queriesResponseSchema = z.object({ - data: z.array(queryRowSchema), -}) - -const verseRowSchema = z.object({ - reference: z.string(), -}) - -const versesResponseSchema = z.object({ - verses: z.array(verseRowSchema), - user_intent: z.string().nullish(), - did_you_mean: z.array(z.string()), - search_instead_for: z.string().nullish(), - next_page_token: z.string().nullish(), -}) - -const topicRowSchema = z.object({ - id: z.number().int().nullish(), - text: z.string(), - subtopics: z.array(z.string()), -}) - -const topicsResponseSchema = z.object({ - topics: z.array(topicRowSchema), - did_you_mean: z.array(z.string()), - search_instead_for: z.string().nullish(), - total_size: z.number().int(), -}) - -/** - * Temporary Search HTTP client until `@youversion/platform-core` ships - * `SearchClient` (YPE-5622). Delete this fetch layer and wrap the shared - * client the way `createHighlightsApi` wraps `HighlightsClient`. - */ export function createSearchApi(config: CreateSearchApiConfig): SearchApi { - const apiHost = config.apiHost ?? DEFAULT_API_HOST - const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS - const headers = { - 'X-YVP-App-Key': config.appKey, - 'X-YVP-Installation-Id': config.installationId, - ...mergeSdkHeaders(config.additionalHeaders), - } + const client = new SearchClient( + new ApiClient({ + appKey: config.appKey, + apiHost: config.apiHost ?? DEFAULT_API_HOST, + installationId: config.installationId, + additionalHeaders: config.additionalHeaders, + timeout: config.timeout, + }), + ) return { - suggestedQueries(params) { - if (params.query === '') { - return Promise.resolve(err(INVALID_PARAMETER)) - } - return fetchQueries({ - apiHost, - headers, - timeoutMs, - languageRanges: params.languageRanges, - query: params.query, - trending: false, - }) + suggestedQueries({ query, languageRanges }) { + return catchAsResult(() => client.getSuggestedQueries(query, languageRanges)) }, - trendingQueries(params) { - return fetchQueries({ - apiHost, - headers, - timeoutMs, - languageRanges: params.languageRanges, - query: undefined, - trending: true, - }) + trendingQueries({ languageRanges }) { + return catchAsResult(() => client.getTrendingQueries(languageRanges)) }, - verses(params) { - return fetchVerses({ apiHost, headers, timeoutMs, params }) + verses({ query, bibleId, ...options }) { + return catchAsResult(() => client.searchVerses(query, bibleId, options)) }, - topics(params) { - return fetchTopics({ apiHost, headers, timeoutMs, params }) + topics({ query, languageRanges }) { + return catchAsResult(() => client.searchTopics(query, languageRanges)) }, } } -async function fetchQueries({ - apiHost, - headers, - timeoutMs, - languageRanges, - query, - trending, -}: { - apiHost: string - headers: Record - timeoutMs: number - languageRanges: string[] - query: string | undefined - trending: boolean -}): Promise> { - if (!isValidLanguageRanges(languageRanges)) { - return err(INVALID_PARAMETER) - } - - const pairs: [string, string][] = languageRanges.map((range) => ['language_ranges[]', range]) - if (query !== undefined) { - pairs.push(['query', query]) - } - if (trending) { - pairs.push(['trending', 'true']) - } - - const response = await sendGet(apiHost, '/v1-beta/search-queries', pairs, headers, timeoutMs) - if (!response.ok) { - return response - } - if (response.value.status === 204) { - return ok([]) - } - const parsed = await parseJson(response.value, queriesResponseSchema) - if (!parsed.ok) { - return parsed - } - return ok(parsed.value.data.map(mapQuery)) -} - -async function fetchVerses({ - apiHost, - headers, - timeoutMs, - params, -}: { - apiHost: string - headers: Record - timeoutMs: number - params: SearchVersesParams -}): Promise> { - const { query, bibleId, pageSize, pageToken } = params - const userIntent = params.userIntent ?? SEARCH_USER_INTENT.unknown - if (!isValidQueryLength(query) || !isValidBibleId(bibleId) || !isValidPageSize(pageSize)) { - return err(INVALID_PARAMETER) - } - - const pairs: [string, string][] = [ - ['query', query], - ['bible_id', String(bibleId)], - ['user_intent', userIntent], - ] - if (pageSize !== undefined) { - pairs.push(['page_size', String(pageSize)]) - } - if (pageToken !== undefined) { - pairs.push(['page_token', pageToken]) - } - - const response = await sendGet(apiHost, '/v1-beta/search-verses', pairs, headers, timeoutMs) - if (!response.ok) { - return response - } - const parsed = await parseJson(response.value, versesResponseSchema) - if (!parsed.ok) { - return parsed - } - const body = parsed.value - return ok({ - verses: body.verses.map(mapVerse), - userIntent: body.user_intent ?? undefined, - didYouMean: body.did_you_mean, - searchInsteadFor: body.search_instead_for ?? undefined, - nextPageToken: body.next_page_token ?? undefined, - }) -} - -async function fetchTopics({ - apiHost, - headers, - timeoutMs, - params, -}: { - apiHost: string - headers: Record - timeoutMs: number - params: SearchTopicsParams -}): Promise> { - const { query, languageRanges } = params - if (!isValidQueryLength(query) || !isValidLanguageRanges(languageRanges)) { - return err(INVALID_PARAMETER) - } - - const pairs: [string, string][] = [ - ['query', query], - ...languageRanges.map((range): [string, string] => ['language_ranges[]', range]), - ] - - const response = await sendGet(apiHost, '/v1-beta/search-topics', pairs, headers, timeoutMs) - if (!response.ok) { - return response - } - const parsed = await parseJson(response.value, topicsResponseSchema) - if (!parsed.ok) { - return parsed - } - const body = parsed.value - return ok({ - topics: body.topics.map(mapTopic), - didYouMean: body.did_you_mean, - searchInsteadFor: body.search_instead_for ?? undefined, - totalSize: body.total_size, - }) -} - -type HttpOk = { status: number; response: Response } - -async function sendGet( - apiHost: string, - path: string, - pairs: [string, string][], - headers: Record, - timeoutMs: number, -): Promise> { - const url = buildUrl(apiHost, path, pairs) - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) +async function catchAsResult(run: () => Promise): Promise> { try { - const response = await fetch(url, { - method: 'GET', - headers, - signal: controller.signal, - }) - const { status } = response - if (status === 401 || status === 403) { - return err({ - kind: 'auth', - status, - message: statusText(status), - }) - } - if (status === 204) { - return ok({ status, response }) - } - if (!response.ok) { - return err({ - kind: 'transient', - status, - message: statusText(status), - }) - } - return ok({ status, response }) + return ok(await run()) } catch (caught) { - const error = caught instanceof Error ? caught : new Error(String(caught)) - return err({ kind: 'transient', message: toMessage(error) }) - } finally { - clearTimeout(timer) + return err(toSearchApiError(caught instanceof Error ? caught : new Error(String(caught)))) } } -async function parseJson( - http: HttpOk, - schema: z.ZodType, -): Promise> { - try { - const json: unknown = await http.response.json() - const parsed = schema.safeParse(json) - if (!parsed.success) { - return err({ kind: 'transient', message: 'Unexpected search API response' }) - } - return ok(parsed.data) - } catch (caught) { - const error = caught instanceof Error ? caught : new Error(String(caught)) - return err({ kind: 'transient', message: toMessage(error) }) - } -} +function toSearchApiError(caught: Error): SearchApiError { + const status = getHttpStatus(caught) + const message = toMessage(caught) -function buildUrl(apiHost: string, path: string, pairs: [string, string][]): string { - const url = new URL(`https://${apiHost}${path}`) - for (const [key, value] of pairs) { - url.searchParams.append(key, value) + if (status === 401 || status === 403) { + return { kind: 'auth', status, message } } - return url.toString() -} - -function isValidQueryLength(query: string): boolean { - return query.length >= 1 && query.length <= 100 -} -function isValidBibleId(bibleId: number): boolean { - return Number.isInteger(bibleId) && bibleId > 0 && bibleId <= INT32_MAX -} - -function isValidPageSize(pageSize: number | undefined): boolean { - if (pageSize === undefined) { - return true - } - return Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 99 -} - -function isValidLanguageRanges(languageRanges: string[]): boolean { - if (languageRanges.length === 0) { - return false - } - return languageRanges.every(isValidLanguageRange) -} - -function isValidLanguageRange(languageRange: string): boolean { - return languageRange === '*' || LANGUAGE_RANGE.test(languageRange) -} - -function mapQuery(row: z.infer): YouVersionSearchQuery { - if (row.source == null) { - return { text: row.text } + if (status === undefined && isZodError(caught)) { + return { kind: 'invalid-parameter', message } } - return { text: row.text, source: row.source } -} -function mapVerse(row: z.infer): YouVersionVerseSearchResult { - return { reference: row.reference } -} - -function mapTopic(row: z.infer): YouVersionSearchTopic { - if (row.id == null) { - return { text: row.text, subtopics: row.subtopics } - } - return { id: row.id, text: row.text, subtopics: row.subtopics } + return status === undefined + ? { kind: 'transient', message } + : { kind: 'transient', status, message } } -function statusText(status: number): string { - return `Request failed with status ${status}` +function isZodError(caught: Error): boolean { + return caught.name === 'ZodError' || caught.name === '$ZodError' } diff --git a/packages/core/src/search/index.ts b/packages/core/src/search/index.ts index 95380a68..bf34af97 100644 --- a/packages/core/src/search/index.ts +++ b/packages/core/src/search/index.ts @@ -8,6 +8,7 @@ export { type SearchVersesParams, type SuggestedQueriesParams, type TrendingQueriesParams, + type YouVersionSearchQueries, type YouVersionSearchQuery, type YouVersionSearchTopic, type YouVersionSearchUserIntent, diff --git a/packages/core/src/search/types.ts b/packages/core/src/search/types.ts index e48f486a..183d7d83 100644 --- a/packages/core/src/search/types.ts +++ b/packages/core/src/search/types.ts @@ -1,3 +1,12 @@ +import type { + SearchQueries, + SearchQuery, + SearchTopic, + SearchTopicsResponse, + SearchVerseHit, + SearchVersesResponse, +} from '@youversion/platform-core' + import type { Result } from '../result' /** @@ -13,35 +22,12 @@ export const SEARCH_USER_INTENT = { unknown: 'unknown', } as const -export type YouVersionSearchQuery = { - text: string - source?: string -} - -export type YouVersionVerseSearchResult = { - reference: string -} - -export type YouVersionVerseSearchResults = { - verses: YouVersionVerseSearchResult[] - userIntent?: YouVersionSearchUserIntent - didYouMean: string[] - searchInsteadFor?: string - nextPageToken?: string -} - -export type YouVersionSearchTopic = { - id?: number - text: string - subtopics: string[] -} - -export type YouVersionTopicSearchResults = { - topics: YouVersionSearchTopic[] - didYouMean: string[] - searchInsteadFor?: string - totalSize: number -} +export type YouVersionSearchQuery = SearchQuery +export type YouVersionSearchQueries = SearchQueries +export type YouVersionVerseSearchResult = SearchVerseHit +export type YouVersionVerseSearchResults = SearchVersesResponse +export type YouVersionSearchTopic = SearchTopic +export type YouVersionTopicSearchResults = SearchTopicsResponse export type SearchApiError = | { kind: 'auth'; status: 401 | 403; message: string } diff --git a/packages/ui/package.json b/packages/ui/package.json index ddf42b06..d78946be 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -60,7 +60,7 @@ "@rn-primitives/popover": "1.4.0", "@rn-primitives/portal": "1.4.0", "@rn-primitives/tabs": "1.4.0", - "@youversion/platform-core": "2.9.0", + "@youversion/platform-core": "2.13.0", "@youversion/platform-react-native-expo-core": "workspace:*", "@youversion/platform-react-ui": "2.12.0", "expo-localization": "56.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3573f804..7d25e5fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,8 +145,8 @@ importers: packages/core: dependencies: '@youversion/platform-core': - specifier: 2.9.0 - version: 2.9.0 + specifier: 2.13.0 + version: 2.13.0 expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) @@ -227,8 +227,8 @@ importers: specifier: 1.4.0 version: 1.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) '@youversion/platform-core': - specifier: 2.9.0 - version: 2.9.0 + specifier: 2.13.0 + version: 2.13.0 '@youversion/platform-react-native-expo-core': specifier: workspace:* version: link:../core @@ -3273,8 +3273,8 @@ packages: jsdom: optional: true - '@youversion/platform-core@2.9.0': - resolution: {integrity: sha512-Uq0q3n9G3qrimUhgi0MRroC2J/iIExHNbJp9Uoa17Yrxr02xxMdmnakG9Jq0rA4hSxJdFDifDygvPH4jStLakA==} + '@youversion/platform-core@2.13.0': + resolution: {integrity: sha512-hMDQ5uXCHBI1ObavbOe1QYu/zu+D6qrQEcLJkSF/OItkfMPmer69x8CZfVfVjhT0k9/dZAREMaq1u+uuV5WbKQ==} peerDependencies: jsdom: ^24.0.0 || ^28.0.0 peerDependenciesMeta: @@ -8327,7 +8327,7 @@ snapshots: - typescript - utf-8-validate - '@expo/cli@56.1.19(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2)': + '@expo/cli@56.1.19(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.11(typescript@7.0.2) @@ -8346,7 +8346,7 @@ snapshots: '@expo/plist': 0.7.0 '@expo/prebuild-config': 56.0.19(typescript@7.0.2) '@expo/require-utils': 56.1.4(typescript@7.0.2) - '@expo/router-server': 56.0.16(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@expo/router-server': 56.0.16(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@expo/schema-utils': 56.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.20.0) @@ -8389,7 +8389,7 @@ snapshots: ws: 8.20.0 zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(395bee51d11edfbc44fb649e096a0f94) + expo-router: 56.2.11(77fafb2832a189933ccae1ef2a2e8424) react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) transitivePeerDependencies: - '@expo/dom-webview' @@ -8604,7 +8604,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) transitivePeerDependencies: - bufferutil - supports-color @@ -8715,6 +8715,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/router-server@56.0.16(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + debug: 4.4.3 + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + expo-constants: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-server: 56.0.5 + react: 19.2.5 + optionalDependencies: + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.14)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-router: 56.2.11(77fafb2832a189933ccae1ef2a2e8424) + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - supports-color + '@expo/router-server@56.0.16(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 @@ -8733,7 +8748,7 @@ snapshots: '@expo/router-server@56.0.16(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: debug: 4.4.3 - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) expo-constants: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)) expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) expo-server: 56.0.5 @@ -11003,7 +11018,7 @@ snapshots: dependencies: zod: 4.1.12 - '@youversion/platform-core@2.9.0': + '@youversion/platform-core@2.13.0': dependencies: zod: 4.1.12 @@ -11420,7 +11435,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) transitivePeerDependencies: - '@babel/core' - supports-color @@ -12286,7 +12301,7 @@ snapshots: expo-application@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) expo-asset@56.0.19(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@7.0.2): dependencies: @@ -12550,6 +12565,59 @@ snapshots: react: 19.2.5 expo-router@56.2.11(395bee51d11edfbc44fb649e096a0f94): + dependencies: + '@expo/log-box': 56.0.14(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.14)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@expo/schema-utils': 56.0.2 + '@expo/ui': 56.0.21(@babel/core@7.29.0)(@types/react@19.2.14)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-tabs': 1.1.21(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + expo-constants: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-linking: 56.0.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-server: 56.0.5 + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.5 + react-fast-compare: 3.2.2 + react-is: 19.2.5 + react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) + react-native-drawer-layout: 4.2.5(9b28c8a9a558aa1f66ede92809843617) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + standard-navigation: 0.0.5 + vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react-test-renderer@19.2.5(react@19.2.5))(react@19.2.5) + react-dom: 19.2.5(react@19.2.5) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + transitivePeerDependencies: + - '@babel/core' + - '@testing-library/dom' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + optional: true + + expo-router@56.2.11(77fafb2832a189933ccae1ef2a2e8424): dependencies: '@expo/log-box': 56.0.14(@expo/dom-webview@56.0.6)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.14)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) @@ -12569,7 +12637,7 @@ snapshots: expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) expo-linking: 56.0.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-symbols: 56.0.6(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -12656,7 +12724,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) expo-server@56.0.5: {} @@ -12676,6 +12744,16 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3) + expo-symbols@56.0.6(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.34 + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) + sf-symbols-typescript: 2.2.0 + optional: true + expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 @@ -12688,7 +12766,7 @@ snapshots: expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 - expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) react: 19.2.5 react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) @@ -12793,7 +12871,7 @@ snapshots: expo@56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.19(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) + '@expo/cli': 56.1.19(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@7.0.2) '@expo/config': 56.0.11(typescript@7.0.2) '@expo/config-plugins': 56.0.12(typescript@7.0.2) '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) From c1f40a5b164ea7e8463b36b0a5dfb76a82822257 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 14:17:18 +0000 Subject: [PATCH 04/12] style(core): drop restated Search comments The USFM tests already pin verse-only three-segment parsing. The exports tests already pin the public surface. Co-authored-by: Dustin Kelley --- packages/core/src/search/__tests__/exports.test.ts | 4 ---- packages/core/src/search/usfm.ts | 5 ----- 2 files changed, 9 deletions(-) diff --git a/packages/core/src/search/__tests__/exports.test.ts b/packages/core/src/search/__tests__/exports.test.ts index a85b7f85..9d7611f3 100644 --- a/packages/core/src/search/__tests__/exports.test.ts +++ b/packages/core/src/search/__tests__/exports.test.ts @@ -1,7 +1,3 @@ -/** - * Guards the Search public surface: `useSearch` and the USFM helper are on the - * package index; the SearchClient wrapper and the Result seam stay internal. - */ import * as core from '../../index' describe('search package exports', () => { diff --git a/packages/core/src/search/usfm.ts b/packages/core/src/search/usfm.ts index d60fc85a..4dbdd99f 100644 --- a/packages/core/src/search/usfm.ts +++ b/packages/core/src/search/usfm.ts @@ -1,10 +1,5 @@ import type { BibleReference } from './types' -/** - * Parses a verse USFM (`BOOK.CHAPTER.VERSE`) into a Bible reference. - * Invalid input — wrong segment count, empty book, or non-positive chapter / - * verse — returns `null`. Reader will drop those later. - */ export function bibleReferenceFromUsfm(usfm: string, versionId: number): BibleReference | null { const parts = usfm.split('.') if (parts.length !== 3) { From 0caa80ff9111a47c9e69856d1e0208b39206fd7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 14:24:08 +0000 Subject: [PATCH 05/12] fix(core): reject unsafe integers in bibleReferenceFromUsfm Digit-only USFM chapter and verse segments can parse past Number.MAX_SAFE_INTEGER. Require a positive safe integer. Co-authored-by: Dustin Kelley --- packages/core/src/search/__tests__/usfm.test.ts | 7 +++++++ packages/core/src/search/usfm.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/search/__tests__/usfm.test.ts b/packages/core/src/search/__tests__/usfm.test.ts index ea4e0508..b1dccb90 100644 --- a/packages/core/src/search/__tests__/usfm.test.ts +++ b/packages/core/src/search/__tests__/usfm.test.ts @@ -19,4 +19,11 @@ describe('bibleReferenceFromUsfm', () => { expect(bibleReferenceFromUsfm('JHN.3.16-18', 111)).toBeNull() expect(bibleReferenceFromUsfm('', 111)).toBeNull() }) + + it('returns null when a chapter or verse is not a safe integer', () => { + const tooManyDigits = '1'.repeat(20) + expect(bibleReferenceFromUsfm(`JHN.${tooManyDigits}.16`, 111)).toBeNull() + expect(bibleReferenceFromUsfm(`JHN.3.${tooManyDigits}`, 111)).toBeNull() + expect(bibleReferenceFromUsfm('JHN.9007199254740992.16', 111)).toBeNull() + }) }) diff --git a/packages/core/src/search/usfm.ts b/packages/core/src/search/usfm.ts index 4dbdd99f..120ff310 100644 --- a/packages/core/src/search/usfm.ts +++ b/packages/core/src/search/usfm.ts @@ -25,7 +25,7 @@ function parsePositiveInt(value: string): number | null { return null } const parsed = Number.parseInt(value, 10) - if (parsed < 1) { + if (!Number.isSafeInteger(parsed) || parsed < 1) { return null } return parsed From 77db50d8694d82e6da681d796528ed338c8edc40 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 10 Sep 2026 11:14:17 -0500 Subject: [PATCH 06/12] feat(ui): add BibleReaderNavigation for chapter jumps Public request/focusReference object so a host can jump the Reader before mount. Scroll-to-verse waits. YPE-5745. Co-authored-by: Cursor --- .changeset/ype-5745-reader-navigation.md | 5 + CONTEXT.md | 6 +- README.md | 19 ++ packages/ui/src/__tests__/exports.test.ts | 19 +- packages/ui/src/index.ts | 3 + .../bible-reader-navigation-reader.test.tsx | 191 ++++++++++++++++++ .../__tests__/bible-reader-navigation.test.ts | 88 ++++++++ .../ui/src/native/bible-reader-navigation.ts | 123 +++++++++++ packages/ui/src/native/bible-reader.tsx | 88 ++++++-- packages/ui/src/native/index.ts | 2 + 10 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 .changeset/ype-5745-reader-navigation.md create mode 100644 packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx create mode 100644 packages/ui/src/native/__tests__/bible-reader-navigation.test.ts create mode 100644 packages/ui/src/native/bible-reader-navigation.ts 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 c6c03ae1..7d2cc25b 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`. `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 e4da7bf4..d53b885d 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,25 @@ function ReaderScreen() { `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. 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(() => createBibleReaderNavigation(), []) + + navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + + 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 0a737efc..488c223a 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') @@ -67,7 +71,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..ab7ccf56 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx @@ -0,0 +1,191 @@ +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(navigation.pendingRequest).toBeNull() + expect(getByTestId('book').props.children).toBe('PSA') + expect(getByTestId('chapter').props.children).toBe('23') + + rerender() + + expect(navigation.pendingRequest).toBeNull() + 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('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(navigation.pendingRequest).toBeNull() + 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..f7e52eef --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts @@ -0,0 +1,88 @@ +import type { BibleReference } from '@youversion/platform-react-native-expo-core' + +import { BibleReaderNavigation, createBibleReaderNavigation } 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) + + expect(navigation.pendingRequest).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) + + expect(navigation.pendingRequest).toEqual({ + reference: JOHN_3_16, + showsFullChapter: false, + scrollsToVerse: true, + shouldFocus: true, + }) + + navigation.focusReference(ROMANS_8_1, false) + + expect(navigation.pendingRequest).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) + + expect(navigation.pendingRequest?.reference).toEqual(ROMANS_8_1) + expect(navigation.pendingRequest?.shouldFocus).toBe(true) + }) + + it('consumePending returns the request once and leaves pending empty', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + + expect(navigation.consumePending()).toEqual({ + reference: JOHN_3_16, + showsFullChapter: true, + scrollsToVerse: false, + shouldFocus: false, + }) + expect(navigation.pendingRequest).toBeNull() + expect(navigation.consumePending()).toBeNull() + }) + + it('createBibleReaderNavigation and new BibleReaderNavigation share the same 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)) + }) +}) 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..1eeaa12b --- /dev/null +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -0,0 +1,123 @@ +import type { BibleReference } from '@youversion/platform-react-native-expo-core' +import { 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 +} + +const subscribeNoop = (): (() => void) => () => {} +const snapshotZero = (): number => 0 + +function copyReference(reference: BibleReference): BibleReference { + return { + versionId: reference.versionId, + bookId: reference.bookId, + chapter: reference.chapter, + verse: reference.verse, + } +} + +/** + * Shared pending-request object a host can create and call before + * `` mounts. One pending request; a newer call replaces an older + * one; the reader consumes it once. + * + * ```tsx + * const navigation = useMemo(() => createBibleReaderNavigation(), []) + * navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + * + * ``` + */ +export class BibleReaderNavigation { + #pending: BibleReaderNavigationRequest | null = null + #version = 0 + #listeners = new Set<() => void>() + + /** + * 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, + }) + } + + get pendingRequest(): BibleReaderNavigationRequest | null { + return this.#pending + } + + subscribe = (onStoreChange: () => void): (() => void) => { + this.#listeners.add(onStoreChange) + return () => { + this.#listeners.delete(onStoreChange) + } + } + + getSnapshot = (): number => this.#version + + /** + * Take the pending request and clear it so a later render cannot apply it + * again. The reader calls this; hosts do not need to. + */ + consumePending(): BibleReaderNavigationRequest | null { + const pending = this.#pending + this.#pending = null + return pending + } + + #setPending(request: BibleReaderNavigationRequest): void { + this.#pending = request + this.#version += 1 + 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. A call before mount still lands on the first reader render. + */ +export function useConsumedNavigationRequest( + navigation: BibleReaderNavigation | undefined, +): BibleReaderNavigationRequest | null { + useSyncExternalStore( + navigation ? navigation.subscribe : subscribeNoop, + navigation ? navigation.getSnapshot : snapshotZero, + navigation ? navigation.getSnapshot : snapshotZero, + ) + if (!navigation) { + return null + } + return navigation.consumePending() +} diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 36d8d2f4..86320201 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -45,6 +45,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 { BibleVerseActionSheet } from './bible-verse-action-sheet' @@ -191,6 +192,15 @@ export type BibleReaderProps = Omit< * as an ordinary prop, so there is no `forwardRef` here. */ ref?: Ref + /** + * Shared pending-request object from {@link BibleReaderNavigation}. 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({ @@ -223,6 +233,7 @@ export function BibleReader({ foregroundColor, dom, ref, + navigation, }: BibleReaderProps): ReactNode { const context = useYouVersion() const { lng } = useLocale() @@ -280,7 +291,47 @@ export function BibleReader({ }, }) - const highlightPermissionFlow = useHighlightPermissionFlow({ versionId, book, chapter }) + 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) + const locationChanged = + requestedBook !== book || requestedChapter !== chapter || requestedVersionId !== versionId + if (locationChanged) { + if (requestedBook !== book) { + setBook(requestedBook) + if (controlledBook === undefined) { + appliedBook = requestedBook + } + } + if (requestedChapter !== chapter) { + setChapter(requestedChapter) + if (controlledChapter === undefined) { + appliedChapter = requestedChapter + } + } + if (requestedVersionId !== versionId) { + setVersionId(requestedVersionId) + if (controlledVersionId === undefined) { + appliedVersionId = requestedVersionId + } + } + } + } + + const highlightPermissionFlow = useHighlightPermissionFlow({ + versionId: appliedVersionId, + book: appliedBook, + chapter: appliedChapter, + }) const { highlights, scope: highlightScope, @@ -326,7 +377,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 @@ -373,11 +431,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 ?? [] @@ -403,10 +461,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 } @@ -444,7 +502,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 } @@ -460,7 +518,7 @@ export function BibleReader({ return _exhaustive } } - }, [authGate, prompt.kind, versionId, book, chapter, replayPendingIntent]) + }, [authGate, prompt.kind, currentScope, replayPendingIntent]) const handleOpenBibleThemeSettings = () => { setIsSettingsSheetOpen(true) @@ -608,9 +666,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} @@ -699,9 +757,9 @@ export function BibleReader({ setIsPickerOpen(false)} - book={book} - chapter={chapter} - versionId={versionId} + book={appliedBook} + chapter={appliedChapter} + versionId={appliedVersionId} theme={resolvedTheme} onSelect={async (data) => { setBook(data.book) @@ -714,7 +772,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' From e5ed75153f9577cf1a6aa91bdd35a0eecf3da1b2 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 21 Sep 2026 10:00:14 -0500 Subject: [PATCH 07/12] fix(ui): keep chapter jumps across discarded renders Consume the pending BibleReaderNavigation request after commit so a retried render cannot drop the host's chapter jump. Co-authored-by: Cursor --- .../__tests__/bible-reader-navigation.test.ts | 61 ++++++++++++++++++- .../ui/src/native/bible-reader-navigation.ts | 42 +++++++++++-- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts index f7e52eef..2e37bfd0 100644 --- a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts +++ b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts @@ -1,6 +1,11 @@ import type { BibleReference } from '@youversion/platform-react-native-expo-core' +import { renderHook } from '@testing-library/react-native' -import { BibleReaderNavigation, createBibleReaderNavigation } from '../bible-reader-navigation' +import { + BibleReaderNavigation, + createBibleReaderNavigation, + useConsumedNavigationRequest, +} from '../bible-reader-navigation' const JOHN_3_16: BibleReference = { versionId: 111, @@ -76,6 +81,40 @@ describe('BibleReaderNavigation', () => { expect(navigation.consumePending()).toBeNull() }) + it('keeps verse fields on an uncommitted pending request', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + + expect(navigation.pendingRequest?.reference.verse).toBe(16) + expect(navigation.pendingRequest?.scrollsToVerse).toBe(false) + expect(navigation.pendingRequest?.shouldFocus).toBe(false) + }) + + it('leaves pending in place until consumeCommitted', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + const version = navigation.getSnapshot() + + expect(navigation.pendingRequest?.reference).toEqual(JOHN_3_16) + expect(navigation.getSnapshot()).toBe(version) + + navigation.consumeCommitted(version) + + expect(navigation.pendingRequest).toBeNull() + }) + + it('does not consume a newer request when committing an older snapshot', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + const firstVersion = navigation.getSnapshot() + navigation.focusReference(ROMANS_8_1) + + navigation.consumeCommitted(firstVersion) + + expect(navigation.pendingRequest?.reference).toEqual(ROMANS_8_1) + expect(navigation.pendingRequest?.shouldFocus).toBe(true) + }) + it('createBibleReaderNavigation and new BibleReaderNavigation share the same API', () => { const created = createBibleReaderNavigation() const constructed = new BibleReaderNavigation() @@ -86,3 +125,23 @@ describe('BibleReaderNavigation', () => { expect(created.focusReference).toEqual(expect.any(Function)) }) }) + +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) + expect(navigation.pendingRequest).toBeNull() + + rerender({ nav: navigation }) + + expect(result.current).toBeNull() + }) +}) diff --git a/packages/ui/src/native/bible-reader-navigation.ts b/packages/ui/src/native/bible-reader-navigation.ts index 1eeaa12b..158f49b7 100644 --- a/packages/ui/src/native/bible-reader-navigation.ts +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -1,5 +1,5 @@ import type { BibleReference } from '@youversion/platform-react-native-expo-core' -import { useSyncExternalStore } from 'react' +import { useLayoutEffect, useRef, useSyncExternalStore } from 'react' /** * One jump the host asked the reader to make. Verse scroll and focus are @@ -39,6 +39,7 @@ function copyReference(reference: BibleReference): BibleReference { export class BibleReaderNavigation { #pending: BibleReaderNavigationRequest | null = null #version = 0 + #pendingVersion = 0 #listeners = new Set<() => void>() /** @@ -91,9 +92,22 @@ export class BibleReaderNavigation { return pending } + /** + * Clear pending only if it is still the request from this snapshot. A newer + * call that replaced it stays. The reader calls this after a render commits; + * hosts do not need to. + */ + consumeCommitted(version: number): void { + if (this.#pendingVersion !== version) { + return + } + this.#pending = null + } + #setPending(request: BibleReaderNavigationRequest): void { this.#pending = request this.#version += 1 + this.#pendingVersion = this.#version for (const listener of this.#listeners) { listener() } @@ -106,18 +120,38 @@ export function createBibleReaderNavigation(): BibleReaderNavigation { /** * Subscribe to a navigation object and consume at most one request per - * version bump. A call before mount still lands on the first reader render. + * 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 { - useSyncExternalStore( + const version = useSyncExternalStore( navigation ? navigation.subscribe : subscribeNoop, navigation ? navigation.getSnapshot : snapshotZero, navigation ? navigation.getSnapshot : snapshotZero, ) + const committedRef = useRef<{ + navigation: BibleReaderNavigation | undefined + version: number + }>({ navigation: undefined, version: 0 }) + + useLayoutEffect(() => { + committedRef.current = { navigation, version } + if (!navigation) { + return + } + navigation.consumeCommitted(version) + }, [navigation, version]) + if (!navigation) { return null } - return navigation.consumePending() + const committedVersion = + committedRef.current.navigation === navigation ? committedRef.current.version : 0 + if (version === committedVersion) { + return null + } + return navigation.pendingRequest } From 4cbb6ed8c92815331535eac43c3d5a91d5112167 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 21 Sep 2026 10:21:50 -0500 Subject: [PATCH 08/12] docs(ui): request the chapter jump once in the host example Call request when creating BibleReaderNavigation so a parent re-render does not queue the same jump again. Co-authored-by: Cursor --- README.md | 8 +++++--- packages/ui/src/native/bible-reader-navigation.ts | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f338a5ed..d41b94a4 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,11 @@ import { useMemo } from 'react' import { BibleReader, createBibleReaderNavigation } from '@youversion/platform-react-native-expo-ui' function ReaderScreen() { - const navigation = useMemo(() => createBibleReaderNavigation(), []) - - navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + const navigation = useMemo(() => { + const readerNavigation = createBibleReaderNavigation() + readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + return readerNavigation + }, []) return } diff --git a/packages/ui/src/native/bible-reader-navigation.ts b/packages/ui/src/native/bible-reader-navigation.ts index 158f49b7..916b04b2 100644 --- a/packages/ui/src/native/bible-reader-navigation.ts +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -31,8 +31,11 @@ function copyReference(reference: BibleReference): BibleReference { * one; the reader consumes it once. * * ```tsx - * const navigation = useMemo(() => createBibleReaderNavigation(), []) - * navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + * const navigation = useMemo(() => { + * const readerNavigation = createBibleReaderNavigation() + * readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + * return readerNavigation + * }, []) * * ``` */ From 28af625be594f681245c85f1c42b1754daa83e45 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 21 Sep 2026 10:39:40 -0500 Subject: [PATCH 09/12] fix(ui): apply reader jumps after the paint commits Keep the first-paint overlay, but wait to set book/chapter/version so a discarded render cannot notify the host of a chapter that never showed. Co-authored-by: Cursor --- packages/ui/src/native/bible-reader.tsx | 55 +++++++++++++++---------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 12a0b166..636857c3 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, @@ -328,30 +329,42 @@ export function BibleReader({ } = pendingNavigation.reference const requestedBook = bookId const requestedChapter = String(requestedChapterNumber) - const locationChanged = - requestedBook !== book || requestedChapter !== chapter || requestedVersionId !== versionId - if (locationChanged) { - if (requestedBook !== book) { - setBook(requestedBook) - if (controlledBook === undefined) { - appliedBook = requestedBook - } - } - if (requestedChapter !== chapter) { - setChapter(requestedChapter) - if (controlledChapter === undefined) { - appliedChapter = requestedChapter - } - } - if (requestedVersionId !== versionId) { - setVersionId(requestedVersionId) - if (controlledVersionId === undefined) { - appliedVersionId = requestedVersionId - } - } + 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. Setters wait for commit so a + // discarded render cannot notify the host or persist a chapter that never + // showed. + useLayoutEffect(() => { + if (!pendingNavigation) { + return + } + const { + bookId, + chapter: requestedChapterNumber, + versionId: requestedVersionId, + } = pendingNavigation.reference + const requestedBook = bookId + const requestedChapter = String(requestedChapterNumber) + 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 From 4f3b9ea30fd89cfbb3b454c53fff0a6a4d2b65b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 18:46:52 +0000 Subject: [PATCH 10/12] fix(ui): hide reader navigation queue controls Keep request and focusReference as the host API. Move subscribe, snapshot, and consume helpers off the exported class so a host cannot clear a jump before the Reader sees it. Drop unused consumePending. Co-authored-by: Dustin Kelley --- .../bible-reader-navigation-reader.test.tsx | 3 - .../__tests__/bible-reader-navigation.test.ts | 89 ++++++++---------- .../ui/src/native/bible-reader-navigation.ts | 92 ++++++++++--------- 3 files changed, 89 insertions(+), 95 deletions(-) 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 index ab7ccf56..d6d726fc 100644 --- a/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx @@ -88,13 +88,11 @@ describe('BibleReader navigation', () => { const { getByTestId, rerender } = render(, { wrapper }) - expect(navigation.pendingRequest).toBeNull() expect(getByTestId('book').props.children).toBe('PSA') expect(getByTestId('chapter').props.children).toBe('23') rerender() - expect(navigation.pendingRequest).toBeNull() expect(getByTestId('chapter').props.children).toBe('23') await act(async () => { @@ -163,7 +161,6 @@ describe('BibleReader navigation', () => { navigation.request({ versionId: 111, bookId: 'JHN', chapter: 1, verse: 16 }) }) - expect(navigation.pendingRequest).toBeNull() expect(getByTestId('book').props.children).toBe('JHN') expect(getByTestId('chapter').props.children).toBe('1') expect(getByTestId('version-id').props.children).toBe('111') diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts index 2e37bfd0..9d32576a 100644 --- a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts +++ b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts @@ -1,5 +1,5 @@ import type { BibleReference } from '@youversion/platform-react-native-expo-core' -import { renderHook } from '@testing-library/react-native' +import { act, renderHook } from '@testing-library/react-native' import { BibleReaderNavigation, @@ -27,7 +27,9 @@ describe('BibleReaderNavigation', () => { navigation.request(JOHN_3_16) - expect(navigation.pendingRequest).toEqual({ + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current).toEqual({ reference: JOHN_3_16, showsFullChapter: true, scrollsToVerse: false, @@ -40,16 +42,20 @@ describe('BibleReaderNavigation', () => { navigation.focusReference(JOHN_3_16) - expect(navigation.pendingRequest).toEqual({ + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current).toEqual({ reference: JOHN_3_16, showsFullChapter: false, scrollsToVerse: true, shouldFocus: true, }) - navigation.focusReference(ROMANS_8_1, false) + act(() => { + navigation.focusReference(ROMANS_8_1, false) + }) - expect(navigation.pendingRequest).toEqual({ + expect(result.current).toEqual({ reference: ROMANS_8_1, showsFullChapter: false, scrollsToVerse: false, @@ -63,59 +69,24 @@ describe('BibleReaderNavigation', () => { navigation.request(JOHN_3_16) navigation.focusReference(ROMANS_8_1, true) - expect(navigation.pendingRequest?.reference).toEqual(ROMANS_8_1) - expect(navigation.pendingRequest?.shouldFocus).toBe(true) - }) + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) - it('consumePending returns the request once and leaves pending empty', () => { - const navigation = createBibleReaderNavigation() - navigation.request(JOHN_3_16) - - expect(navigation.consumePending()).toEqual({ - reference: JOHN_3_16, - showsFullChapter: true, - scrollsToVerse: false, - shouldFocus: false, - }) - expect(navigation.pendingRequest).toBeNull() - expect(navigation.consumePending()).toBeNull() + 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) - expect(navigation.pendingRequest?.reference.verse).toBe(16) - expect(navigation.pendingRequest?.scrollsToVerse).toBe(false) - expect(navigation.pendingRequest?.shouldFocus).toBe(false) - }) - - it('leaves pending in place until consumeCommitted', () => { - const navigation = createBibleReaderNavigation() - navigation.request(JOHN_3_16) - const version = navigation.getSnapshot() - - expect(navigation.pendingRequest?.reference).toEqual(JOHN_3_16) - expect(navigation.getSnapshot()).toBe(version) - - navigation.consumeCommitted(version) + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) - expect(navigation.pendingRequest).toBeNull() - }) - - it('does not consume a newer request when committing an older snapshot', () => { - const navigation = createBibleReaderNavigation() - navigation.request(JOHN_3_16) - const firstVersion = navigation.getSnapshot() - navigation.focusReference(ROMANS_8_1) - - navigation.consumeCommitted(firstVersion) - - expect(navigation.pendingRequest?.reference).toEqual(ROMANS_8_1) - expect(navigation.pendingRequest?.shouldFocus).toBe(true) + 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 same API', () => { + it('createBibleReaderNavigation and new BibleReaderNavigation share the host API', () => { const created = createBibleReaderNavigation() const constructed = new BibleReaderNavigation() @@ -123,6 +94,11 @@ describe('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') }) }) @@ -138,10 +114,25 @@ describe('useConsumedNavigationRequest', () => { expect(result.current?.reference).toEqual(JOHN_3_16) expect(result.current?.reference.verse).toBe(16) - expect(navigation.pendingRequest).toBeNull() 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 index 916b04b2..cf44af27 100644 --- a/packages/ui/src/native/bible-reader-navigation.ts +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -13,7 +13,9 @@ export type BibleReaderNavigationRequest = { shouldFocus: boolean } -const subscribeNoop = (): (() => void) => () => {} +type Subscribe = (onStoreChange: () => void) => () => void + +const subscribeNoop: Subscribe = () => () => {} const snapshotZero = (): number => 0 function copyReference(reference: BibleReference): BibleReference { @@ -25,6 +27,23 @@ function copyReference(reference: BibleReference): BibleReference { } } +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 +} + /** * Shared pending-request object a host can create and call before * `` mounts. One pending request; a newer call replaces an older @@ -45,6 +64,25 @@ export class BibleReaderNavigation { #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. @@ -72,41 +110,6 @@ export class BibleReaderNavigation { }) } - get pendingRequest(): BibleReaderNavigationRequest | null { - return this.#pending - } - - subscribe = (onStoreChange: () => void): (() => void) => { - this.#listeners.add(onStoreChange) - return () => { - this.#listeners.delete(onStoreChange) - } - } - - getSnapshot = (): number => this.#version - - /** - * Take the pending request and clear it so a later render cannot apply it - * again. The reader calls this; hosts do not need to. - */ - consumePending(): BibleReaderNavigationRequest | null { - const pending = this.#pending - this.#pending = null - return pending - } - - /** - * Clear pending only if it is still the request from this snapshot. A newer - * call that replaced it stays. The reader calls this after a render commits; - * hosts do not need to. - */ - consumeCommitted(version: number): void { - if (this.#pendingVersion !== version) { - return - } - this.#pending = null - } - #setPending(request: BibleReaderNavigationRequest): void { this.#pending = request this.#version += 1 @@ -130,11 +133,14 @@ export function createBibleReaderNavigation(): BibleReaderNavigation { export function useConsumedNavigationRequest( navigation: BibleReaderNavigation | undefined, ): BibleReaderNavigationRequest | null { - const version = useSyncExternalStore( - navigation ? navigation.subscribe : subscribeNoop, - navigation ? navigation.getSnapshot : snapshotZero, - navigation ? navigation.getSnapshot : snapshotZero, - ) + 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 @@ -145,7 +151,7 @@ export function useConsumedNavigationRequest( if (!navigation) { return } - navigation.consumeCommitted(version) + accessFor(navigation).consumeCommitted(version) }, [navigation, version]) if (!navigation) { @@ -156,5 +162,5 @@ export function useConsumedNavigationRequest( if (version === committedVersion) { return null } - return navigation.pendingRequest + return accessFor(navigation).peekPending() } From 39d266b84ebd544047c412331edb679bba696fd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 18:56:09 +0000 Subject: [PATCH 11/12] fix(ui): persist reader jumps as one location Write book, chapter, and version in a single Reader Location patch so an uncontrolled jump cannot save a mixed book with the old chapter. Co-authored-by: Dustin Kelley --- .../bible-reader-navigation-reader.test.tsx | 43 +++++++++++ packages/ui/src/native/bible-reader.tsx | 75 +++++++++++++------ 2 files changed, 94 insertions(+), 24 deletions(-) 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 index d6d726fc..14db8c65 100644 --- a/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx @@ -138,6 +138,49 @@ describe('BibleReader navigation', () => { 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() diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 636857c3..2d108d14 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -278,7 +278,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) @@ -290,7 +294,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) @@ -302,7 +310,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) @@ -317,6 +329,27 @@ export function BibleReader({ lastAppliedRef.current = null }) + 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 @@ -340,9 +373,8 @@ export function BibleReader({ } } - // Overlay paints the jump on this render. Setters wait for commit so a - // discarded render cannot notify the host or persist a chapter that never - // showed. + // 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 @@ -354,6 +386,18 @@ export function BibleReader({ } = 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) } @@ -397,24 +441,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) } From 548bcb48ce5ce8bcf7e50c345a2852a07d921b21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 19:00:07 +0000 Subject: [PATCH 12/12] docs(ui): note one Reader per navigation object Hosts should pass one BibleReaderNavigation to one mounted Reader. Broadcast across stacked readers is left for a later issue if needed. Co-authored-by: Dustin Kelley --- CONTEXT.md | 2 +- README.md | 2 +- packages/ui/src/native/bible-reader-navigation.ts | 6 +++--- packages/ui/src/native/bible-reader.tsx | 7 ++++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8cbb3ac6..09016edb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,7 +51,7 @@ The last committed Bible location (`book`, `chapter`, `versionId`) a **Native Wr _Avoid_: **Reader Navigation** (the pending-request object); passage state **Reader Navigation**: -The public pending-request object (`BibleReaderNavigation`) a host creates and passes into `BibleReader`. `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**. +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**: diff --git a/README.md b/README.md index d41b94a4..2c996978 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ On iOS and Android, avatar, chapter (with prev/next), version, and settings live #### Jumping to a passage -Create a `BibleReaderNavigation` object and pass it in. You can call it before the reader mounts. A newer call replaces an older one; the reader consumes each request once. +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' diff --git a/packages/ui/src/native/bible-reader-navigation.ts b/packages/ui/src/native/bible-reader-navigation.ts index cf44af27..18a1d4ca 100644 --- a/packages/ui/src/native/bible-reader-navigation.ts +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -45,9 +45,9 @@ function accessFor(navigation: BibleReaderNavigation): ReaderNavigationAccess { } /** - * Shared pending-request object a host can create and call before - * `` mounts. One pending request; a newer call replaces an older - * one; the reader consumes it once. + * 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(() => { diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 2d108d14..fab6e0e4 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -198,9 +198,10 @@ export type BibleReaderProps = Omit< */ ref?: Ref /** - * Shared pending-request object from {@link BibleReaderNavigation}. Call - * `request` or `focusReference` before or after mount. One pending request; - * a newer call replaces an older one; this reader consumes it once. + * 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.