Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/bible-reader-highlights-api.md
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.
85 changes: 85 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 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 default `BibleReader` posture (YPE-1034): the reader fetches and writes
highlights itself through the SDK's own auth session. The color row is
always offered; tapping a color when the user lacks a session or the
highlights permission enters the highlight auth flow (July 9 2026 sync,
superseding the earlier hide-when-signed-out idea).

## Highlights permission

The per-app grant that authorizes highlight reads/writes for a user. It is
**not an OIDC scope**: it travels as `requested_permissions[]=highlights`
alongside `scope` at authorize time (PR #280) and is granted via a data
exchange consent. Permissions are open-ended strings, never enums (more
arrive later, e.g. verse notes). The **server is the source of truth** for
whether it is granted; any client-side permission cache is optimistic only.

## Pending highlight

The user's stashed highlight intent (verses + color + timestamp) while the
highlight auth flow is in flight. Survives a redirect round-trip via
sessionStorage; expires stale (~10 min) so an abandoned round-trip can
never apply a highlight during a later sign-in. Discarded on decline,
cancel, or failure.

## Highlight auth flow

The state machine (see the React Web auth state machine doc) that turns a
color tap into an applied highlight across two user paths: one-fell-swoop
(sign-in requesting the highlights permission together) and just-in-time
(already signed in, permission confirm dialog → data exchange grant).
Cancellation at any point keeps the verse selection intact and discards
only the pending highlight.

## Controlled mode

A planned `BibleReader` posture (YPE-3705): the host application supplies
rendered highlights as data and receives highlight-intent events, and the
reader makes no highlight network calls. The host owns auth, persistence,
and conflict rules. Used by native hosts (RN Expo SDK) embedding the web
reader.

## Verse action popover

The floating action bar (YPE-642) that appears over a verse selection,
offering highlight colors, copy, and share.
52 changes: 52 additions & 0 deletions docs/adr/YPE-1034-highlights-server-only.md
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.
3 changes: 3 additions & 0 deletions docs/adr/YPE-642-verse-action-popover.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ gone.** #131's `VerseActionPopover` (correct AC logic, tested, already uses Radi
## Decisions (ADRs)

### ADR-001 — localStorage only this PR
> **Superseded** by [YPE-1034 ADR-001](./YPE-1034-highlights-server-only.md):
> highlights are server-only; the localStorage store is removed.

Highlights persist client-side only. Server sync is a **separate ticket**.
No network, no API client this PR.

Expand Down
4 changes: 4 additions & 0 deletions packages/hooks/src/context/index.ts
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';
175 changes: 175 additions & 0 deletions packages/hooks/src/useApiData.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useApiData, type UseApiDataOptions } from './useApiData';

type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
};

function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

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: Deferred<string>[] = [];
const fetchFn = vi.fn(() => {
const deferred = createDeferred<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: Deferred<string>[] = [];
const fetchFn = vi.fn(() => {
const deferred = createDeferred<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);
});
});
Loading
Loading