Skip to content
Merged
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.
51 changes: 51 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Ubiquitous Language
Comment thread
cameronapak marked this conversation as resolved.

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.
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';
159 changes: 159 additions & 0 deletions packages/hooks/src/useApiData.test.tsx
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);
});
});
42 changes: 28 additions & 14 deletions packages/hooks/src/useApiData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';

export type UseApiDataOptions = {
enabled?: boolean;
Expand All @@ -24,48 +24,62 @@ export function useApiData<T>(
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

// Monotonic sequence per issued request: only the latest-issued request may
// commit state. This covers refetch-initiated requests too, which a
// per-effect cancel closure would miss — a stale refetch (e.g. for a
// previous chapter) resolving after a newer fetch must not overwrite it.
const requestSeqRef = useRef(0);

const fetchData = useCallback(() => {
const requestSeq = ++requestSeqRef.current;

if (!enabled) {
// Disabling drops previously fetched data instead of keeping it: the
// usual reason to disable is that the data must no longer be shown
// (signed out, auth switched to a different user), and leaking stale
// account data across sessions is worse than a refetch on re-enable.
setData(null);
setLoading(false);
setError(null);
return;
}

let canceled = false;

setLoading(true);
setError(null);

fetchFn()
.then((result) => {
if (!canceled) {
if (requestSeq === requestSeqRef.current) {
setData(result);
}
})
.catch((err) => {
if (!canceled) {
if (requestSeq === requestSeqRef.current) {
setError(err as Error);
}
})
.finally(() => {
if (!canceled) {
if (requestSeq === requestSeqRef.current) {
setLoading(false);
}
});

return () => {
canceled = true;
};
}, [fetchFn, enabled]);

const refetch = useCallback(() => {
fetchData();
}, [fetchData]);

// `enabled` rides alongside the caller-supplied deps so a false→true flip
// (e.g. auth resolving after mount, with the caller's deps unchanged)
// actually triggers the fetch.
useEffect(() => {
Comment thread
cameronapak marked this conversation as resolved.
const cleanup = fetchData();
return cleanup;
// @eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
fetchData();
return () => {
// Invalidate any in-flight request (effect- or refetch-initiated) when
// the deps change or the component unmounts.
requestSeqRef.current++;
};
}, [...deps, enabled]);

return { data, loading, error, refetch };
}
1 change: 1 addition & 0 deletions packages/hooks/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "@internal/tsconfig/react.json",
"compilerOptions": {
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"outDir": "dist",
"rootDir": ".",
"baseUrl": ".",
Expand Down
Loading
Loading