-
Notifications
You must be signed in to change notification settings - Fork 3
Wire BibleReader highlights to the highlights API (YPE-1034) (1/4) #283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
24237c3
docs(ype-1034): ADR + glossary for server-only highlights
cameronapak 3fb8f0a
feat(ui): wire BibleReader highlights to the highlights API behind a …
cameronapak 19295d6
fix(hooks): fetch on enabled flip, clear data on disable, latest-wins…
cameronapak 7f66a99
chore(hooks): drop inert lint-disable comment
cameronapak 194ee6f
Merge branch 'main' into claude/ype-1034-pr1-highlights-api
cameronapak 65f61b2
fix(ui): scope-gate confirmed highlight verses and drop non-null over…
cameronapak c636c50
fix(ui): scope-gate failure-path overlay reverts
cameronapak a36263a
refactor: apply non-blocking review suggestions from PR 283
cameronapak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| '@youversion/platform-react-hooks': patch | ||
| '@youversion/platform-react-ui': patch | ||
| --- | ||
|
|
||
| BibleReader highlights now wire to the highlights API behind an internal dark-launch flag; the localStorage highlight store is removed. | ||
|
|
||
| - Highlights are server-only account data (YPE-1034): the temporary `youversion-platform:highlights:<versionId>` localStorage store is deleted outright, with no migration. | ||
| - A new internal `useBibleReaderHighlights` seam fetches the current chapter's highlights via `useHighlights`, renders them through an in-memory optimistic overlay (reverted on write failure), and collapses contiguous verse selections into range USFMs (e.g. `JHN.3.16-18`) on the wire. | ||
| - Everything is gated on an internal `HIGHLIGHTS_LIVE` flag (currently off) plus an authenticated session: while off or signed out, the color row is inert — no fetches, no writes, no rendered highlights. Signing out un-renders highlights immediately. | ||
| - `@youversion/platform-react-hooks` now exports `YouVersionAuthContext`, the no-throw alternative to `useYVAuth` for components that must tolerate a missing auth provider (its error message already advertised the export). | ||
| - `useApiData` (the fetch engine behind all data hooks, e.g. `useHighlights`, `usePassage`) fixes and behavior changes: | ||
| - An `enabled` false→true flip now triggers the fetch even when the caller's deps are unchanged — previously a hook that mounted disabled (e.g. waiting on auth) never fetched after being enabled. | ||
| - Disabling now clears `data` (and `error`) instead of keeping the last response, so stale account data cannot linger after sign-out or an auth user switch. | ||
| - Responses are now latest-wins across `refetch` too: a stale in-flight request (e.g. a refetch for a previous chapter) resolving after a newer fetch no longer overwrites its data. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Ubiquitous Language | ||
|
|
||
| Glossary of domain terms for the YouVersion Platform SDK. Terms here are | ||
| canonical: code, docs, and conversation should use them exactly. | ||
|
|
||
| ## Highlight | ||
|
|
||
| A user-owned color marking on a Bible passage, stored on the user's | ||
| YouVersion account. Highlights are **account data, not device data**: they | ||
| require an authenticated user and are never persisted locally by the SDK | ||
| (decided in YPE-1034, superseding the temporary localStorage store from | ||
| YPE-642 / ADR-001). | ||
|
|
||
| Identified by the pair (**Bible version**, **passage**). A highlight has | ||
| exactly one **color**. | ||
|
|
||
| ## Passage | ||
|
|
||
| A verse or contiguous verse range in one chapter of one Bible version, | ||
| identified by a USFM string (`JHN.3.16`, `JHN.3.16-18`). A chapter USFM | ||
| (`JHN.3`) is a passage *scope* used for querying, not a highlightable unit. | ||
|
|
||
| ## Bible version | ||
|
|
||
| A translation/edition of the Bible, identified by a numeric id. The SDK | ||
| calls this `version_id`; the highlights wire API calls the same value | ||
| `bible_id`. The SDK name is canonical in public types; mapping happens at | ||
| the API boundary only. | ||
|
|
||
| ## Color | ||
|
|
||
| A highlight's fill, a 6-character lowercase hex string without `#` | ||
| (`fff9b1`). Uppercase input is accepted and normalized at the API boundary. | ||
|
|
||
| ## Verse selection | ||
|
|
||
| The ephemeral set of verses the reader has tapped in `BibleReader`. Drives | ||
| the verse action popover. Never persisted; cleared on navigation. | ||
|
|
||
| ## Self-contained mode | ||
|
|
||
| The `BibleReader` posture (YPE-1034): the reader fetches and writes | ||
| highlights itself through the SDK's own auth session. Highlight behavior is | ||
| gated on the internal `HIGHLIGHTS_LIVE` dark-launch flag and an | ||
| authenticated user — while the flag is off or the user has no session, the | ||
| reader is inert: no fetches, no writes, nothing rendered from the API. | ||
|
|
||
| ## Verse action popover | ||
|
|
||
| The floating action bar (YPE-642) that appears over a verse selection, | ||
| offering highlight colors, copy, and share. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # YPE-1034 — Wire the highlights API into BibleReader | ||
|
|
||
| Status: **Decided** (grilling session 2026-07-10, Cam + Dustin) | ||
| Component: `packages/ui/src/components/bible-reader.tsx` (+ hooks `useHighlights`) | ||
| Inputs: July 9 2026 highlights sync (Notion: "React Web SDK Highlights: | ||
| Implementation Brief"), auth state machine doc, YPE-642 gotchas doc, | ||
| Swift reference PR platform-sdk-swift#179. | ||
|
|
||
| ## ADR-001 — Highlights are server-only; the localStorage store is removed | ||
|
|
||
| **Supersedes ADR-001 in [YPE-642](./YPE-642-verse-action-popover.md).** | ||
|
|
||
| ### Decision | ||
|
|
||
| The localStorage highlight store (`youversion-platform:highlights:<versionId>`) | ||
| is deleted outright — no migration, no signed-out fallback. Highlights are | ||
| fetched from and written to `/v1/highlights` exclusively, through the SDK's | ||
| authenticated session. A user with no session (or whose app lacks the | ||
| `highlights` permission) enters the highlight auth flow when they tap a color; | ||
| their intent is stashed as a **pending highlight** (sessionStorage, ~10-minute | ||
| expiry), never as a persisted local highlight. | ||
|
|
||
| The only local traces of highlight state are: | ||
| - the in-memory optimistic overlay while a write is in flight, | ||
| - the pending highlight during an auth round-trip, | ||
| - an optimistic localStorage cache of the *permission* grant (not highlight | ||
| data), which the server can invalidate at any time via 401/403. | ||
|
|
||
| ### Why | ||
|
|
||
| - Highlights are account data. A browser-profile copy silently diverges from | ||
| the user's YouVersion account and dies at the browser boundary. | ||
| - YPE-642's store was explicitly a stand-in for this ticket (its ADR-001 said | ||
| "server sync is a separate ticket" — this is that ticket). | ||
| - The SDK is pre-1.0 with few consumers; the migration code for weeks-old | ||
| throwaway data would outlive its usefulness. | ||
| - Two persistence paths (API + local) double the state machine and create an | ||
| unanswerable merge question on sign-in. | ||
|
|
||
| ### Consequences | ||
|
|
||
| - Sign-out immediately un-renders all highlights. | ||
| - Signed-out readers see no highlights; that is correct, not a regression. | ||
| - The RN Expo / native-host story (YPE-3705) supplies highlights via | ||
| controlled props instead — it does not resurrect local persistence. | ||
|
|
||
| ### Alternatives rejected | ||
|
|
||
| - **localStorage for signed-out + API for signed-in:** merge-on-sign-in | ||
| conflicts, doubled state machine, data that still dies per-device. | ||
| - **One-time migration of existing local highlights:** permanent code for | ||
| transient data nobody has accumulated meaningfully. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,6 @@ | ||
| export * from './YouVersionContext'; | ||
| export * from './YouVersionProvider'; | ||
| // The raw auth context (no-throw alternative to `useYVAuth` for consumers that | ||
| // must tolerate a missing auth provider). Its own error message already | ||
| // advertises it as importable from this package. | ||
| export { YouVersionAuthContext } from './YouVersionAuthContext'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { act, renderHook, waitFor } from '@testing-library/react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { useApiData, type UseApiDataOptions } from './useApiData'; | ||
|
|
||
| describe('useApiData — enabled transitions', () => { | ||
| it('fetches when enabled flips from false to true with unchanged deps', async () => { | ||
| const fetchFn = vi.fn().mockResolvedValue('payload'); | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ enabled }: { enabled: boolean }) => useApiData(fetchFn, ['stable-dep'], { enabled }), | ||
| { initialProps: { enabled: false } }, | ||
| ); | ||
|
|
||
| // Disabled on mount (e.g. auth still resolving): no request goes out. | ||
| expect(fetchFn).not.toHaveBeenCalled(); | ||
| expect(result.current.loading).toBe(false); | ||
| expect(result.current.data).toBeNull(); | ||
|
|
||
| // Auth resolves: enabled flips true while the caller's deps are unchanged. | ||
| rerender({ enabled: true }); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.data).toBe('payload'); | ||
| }); | ||
| expect(fetchFn).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('clears data (without refetching) when enabled flips from true to false', async () => { | ||
| const fetchFn = vi.fn().mockResolvedValue('user-a-data'); | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ enabled }: { enabled: boolean }) => useApiData(fetchFn, ['stable-dep'], { enabled }), | ||
| { initialProps: { enabled: true } }, | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.data).toBe('user-a-data'); | ||
| }); | ||
|
|
||
| // Sign-out (or auth switching users): stale account data must not linger. | ||
| rerender({ enabled: false }); | ||
|
|
||
| expect(result.current.data).toBeNull(); | ||
| expect(result.current.error).toBeNull(); | ||
| expect(result.current.loading).toBe(false); | ||
| expect(fetchFn).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe('useApiData — stale responses (latest wins)', () => { | ||
| it('ignores a stale refetch response that resolves after a newer fetch', async () => { | ||
| const deferreds: PromiseWithResolvers<string>[] = []; | ||
| const fetchFn = vi.fn(() => { | ||
| const deferred = Promise.withResolvers<string>(); | ||
| deferreds.push(deferred); | ||
| return deferred.promise; | ||
| }); | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ scope }: { scope: string }) => useApiData(fetchFn, [scope]), | ||
| { initialProps: { scope: 'JHN.3' } }, | ||
| ); | ||
|
|
||
| // Initial fetch for JHN.3 resolves normally. | ||
| await act(async () => { | ||
| deferreds[0]!.resolve('JHN.3 data'); | ||
| await Promise.resolve(); | ||
| }); | ||
| expect(result.current.data).toBe('JHN.3 data'); | ||
|
|
||
| // A refetch for JHN.3 goes out (e.g. after a highlight write)… | ||
| act(() => { | ||
| result.current.refetch(); | ||
| }); | ||
| expect(deferreds).toHaveLength(2); | ||
|
|
||
| // …then the user navigates to JHN.4, whose fetch resolves first. | ||
| rerender({ scope: 'JHN.4' }); | ||
| expect(deferreds).toHaveLength(3); | ||
| await act(async () => { | ||
| deferreds[2]!.resolve('JHN.4 data'); | ||
| await Promise.resolve(); | ||
| }); | ||
| expect(result.current.data).toBe('JHN.4 data'); | ||
|
|
||
| // The stale JHN.3 refetch finally lands — it must not clobber JHN.4. | ||
| await act(async () => { | ||
| deferreds[1]!.resolve('stale JHN.3 data'); | ||
| await Promise.resolve(); | ||
| }); | ||
| expect(result.current.data).toBe('JHN.4 data'); | ||
| expect(result.current.loading).toBe(false); | ||
| }); | ||
|
|
||
| it('ignores a stale error from an invalidated request', async () => { | ||
| const deferreds: PromiseWithResolvers<string>[] = []; | ||
| const fetchFn = vi.fn(() => { | ||
| const deferred = Promise.withResolvers<string>(); | ||
| deferreds.push(deferred); | ||
| return deferred.promise; | ||
| }); | ||
|
|
||
| const { result, rerender } = renderHook( | ||
| ({ scope }: { scope: string }) => useApiData(fetchFn, [scope]), | ||
| { initialProps: { scope: 'JHN.3' } }, | ||
| ); | ||
|
|
||
| // Navigate away while the first request is still in flight. | ||
| rerender({ scope: 'JHN.4' }); | ||
| await act(async () => { | ||
| deferreds[1]!.resolve('JHN.4 data'); | ||
| await Promise.resolve(); | ||
| }); | ||
| expect(result.current.data).toBe('JHN.4 data'); | ||
|
|
||
| // The abandoned JHN.3 request fails late — the error must not surface. | ||
| await act(async () => { | ||
| deferreds[0]!.reject(new Error('stale failure')); | ||
| await Promise.resolve(); | ||
| }); | ||
| expect(result.current.error).toBeNull(); | ||
| expect(result.current.data).toBe('JHN.4 data'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('useApiData — existing behavior', () => { | ||
| it('does not fetch when enabled is false for the whole lifetime', () => { | ||
| const fetchFn = vi.fn().mockResolvedValue('never'); | ||
| const options: UseApiDataOptions = { enabled: false }; | ||
|
|
||
| const { result } = renderHook(() => useApiData(fetchFn, ['dep'], options)); | ||
|
|
||
| expect(fetchFn).not.toHaveBeenCalled(); | ||
| expect(result.current.loading).toBe(false); | ||
| expect(result.current.data).toBeNull(); | ||
| }); | ||
|
|
||
| it('refetches on demand', async () => { | ||
| const fetchFn = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); | ||
|
|
||
| const { result } = renderHook(() => useApiData(fetchFn, ['dep'])); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.data).toBe('first'); | ||
| }); | ||
|
|
||
| act(() => { | ||
| result.current.refetch(); | ||
| }); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.data).toBe('second'); | ||
| }); | ||
| expect(fetchFn).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.