Skip to content

feat(core): highlights refetch when the app returns to the foreground (YPE-4491) - #123

Draft
Dustin-Kelley wants to merge 1 commit into
highlightsfrom
dk/foreground-refetch
Draft

feat(core): highlights refetch when the app returns to the foreground (YPE-4491)#123
Dustin-Kelley wants to merge 1 commit into
highlightsfrom
dk/foreground-refetch

Conversation

@Dustin-Kelley

@Dustin-Kelley Dustin-Kelley commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What problem was I solving

useHighlights re-fetched on mount and on a scope change, and nothing else. So a highlight created elsewhere — in the YouVersion app, on youversion.com, on another device — never appeared until the reader remounted or the user changed chapter. Coming back to the app showed stale paint.

Two triggers were missing: app foreground and navigation focus. This PR adds the first inside the SDK and hands the second to the host, deliberately.

Shipped, this means: background the app, highlight a verse somewhere else, foreground it — the highlight is there. Switch tabs away and back in the example app — same.

What user-facing changes did I ship

  • packages/core/src/highlights/use-highlights.ts — the hook subscribes to AppState and re-fetches when the app returns to the foreground. Zero config: every direct hook consumer inherits it.
  • packages/ui/src/native/bible-reader.tsxBibleReader accepts a ref exposing refreshHighlights(). New exported type BibleReaderHandle, re-exported from the package index alongside the verse-selection payload types.
  • apps/example/app/(tabs)/index.tsx — the copyable useFocusEffect recipe, ~5 lines, kept showcase-clean.

Minor × both packages. Purely additive: no existing prop, type, or behaviour changes.

How I implemented it

Core — the foreground listener

A per-instance AppState listener in useHighlights, matching the auth provider's own-subscription pattern. Not a shared listener plus a registry: RN handles many listeners fine, and exactly one useHighlights mounts per BibleReader (via useHighlightPermissionFlow).

The transition rule is a pure predicate next to shouldFetchHighlights:

export function shouldRefetchOnForeground(previous: AppStateStatus, next: AppStateStatus): boolean {
  return previous === 'background' && next === 'active'
}

background → active only, never inactive → active. On iOS expo-web-browser parks the app in inactive for PKCE sign-in and the just-in-time consent page, and both of those already re-fetch on the identity or token change they cause — firing on inactive would double-fetch every round-trip. It is also deliberately stricter than the auth provider's unfiltered active listener (auth-provider.tsx): that one guards a leeway-gated token refresh that is nearly free, this one guards a network GET. Do not align them in either direction. The truth table is a layer-1 test precisely so a future "simplification" to state === 'active' goes red.

The listener carries no gating of its own. runFetch already early-returns for an app that never requested the highlights permission, early-returns for a signed-out user, and joins whatever request is in flight. The event only carries the next state, so the previous one is tracked in a ref seeded from AppState.currentState.

Known and accepted: on Android the Custom Tab is a separate activity, so the host app really does go to background — a consent round-trip there arrives as exactly the transition we listen for and may cost one extra GET. It is idempotent and reconciled by ADR 0013's overlay layer, and single-flight coalesces it with the flow's own fetch when the timing overlaps. Rejected alternatives: suppressing during an in-flight auth session (cross-module coupling into the auth provider's refs) and a debounce window (a tuning knob that can swallow a legitimate fast round-trip). AC 2 is therefore scoped to iOS.

UI — the handle

const reader = useRef<BibleReaderHandle>(null)
useFocusEffect(useCallback(() => {
  void reader.current?.refreshHighlights()
}, []))
return <BibleReader ref={reader} />

Navigation focus is not SDK-detected on purpose. Detecting it would force @react-navigation/native on every consumer as a peer dependency for a trigger many apps don't need. So the reader exposes one method and the host calls it.

React 19 ref-as-prop; no forwardRef. useImperativeHandle hands back core's own refresh, not a wrapper — it never rejects (fetch failures land in error) and concurrent calls join the one request in flight, so a host can honestly await it for a RefreshControl. A void return would have been a smaller surface that just deletes a guarantee core already provides. The screen test asserts promise identity for that reason: a fire-and-forget wrapper would satisfy every weaker assertion while resolving before the GET does, and the host's spinner would stop early.

Tests

Layer File Pins
1 should-refetch-on-foreground.test.ts (new) 7-row truth table: background→active ✓, everything else ✗.
3 use-highlights.test.tsx (extended) Subscribes on mount / removes on unmount; background→active issues a second GET and paints the new highlight; inactive→active issues none; a foreground refetch while one is in flight joins it.
3 use-highlights.test.tsx (extended) AC 3 regression: chapter change away and back re-fetches each time, asserting passage_id on all three GETs. Worked already; was unpinned.
3 bible-reader-refresh-handle.test.tsx (new) The ref exposes refreshHighlights; it returns core's refresh promise by identity; the handle clears on unmount.

The repo splits reader tests one file per concern, hence a new file rather than extending bible-reader.test.tsx (which doesn't exist).

Both new behavioural tests were mutation-checked: stubbing out the predicate call fails the foreground test, and wrapping the handle in a fire-and-forget async fails the identity test.

pnpm typecheck 5/5 · pnpm test core 423 passed, ui 309 passed + 1 skipped · pnpm lint clean · prettier clean.

Docs

AGENTS.md gets the two bullets that stop this being re-litigated: the foreground trigger and why it's stricter than the auth provider's listener, and navigation focus being host-triggered by design. Consumer usage docs for the recipe are YPE-4102's — this PR lands the BibleReaderHandle JSDoc and the AGENTS.md note only, so the two tickets don't double-cover it.

Not done yet — manual device verification

The three ACs automation can't reach are still outstanding, and I'll record them on this PR before it leaves draft (3710's lesson: unrecorded device ACs became the only thing between the ticket and Done):

  1. Two-device freshness (AC 1) — highlight in the YouVersion app as the same user, background/foreground the example app, confirm it paints without a remount.
  2. iOS consent round-trip (AC 2) — run the JIT consent flow with network inspection; expect exactly one highlights GET on return (the flow's own), none from the listener.
  3. Android spot-check — documents decision 1, not an AC: confirm the extra GET is at most one, with no flicker.

Out of scope

  • VerseOfTheDay / BibleTextView highlight surfaces → YPE-4492.
  • Consumer usage docs for the recipe → YPE-4102.
  • Any change to the fetch gate, single-flight, overlay reconciliation, or clearSelectionSignal — all reused as-is.
  • Suppression/debounce machinery for the Android extra GET — rejected above.

🤖 Generated with Claude Code

… (YPE-4491)

Highlights created in the YouVersion app, on youversion.com, or on another
device now appear when the user comes back, instead of waiting for a remount
or a chapter change.

`useHighlights` subscribes to `AppState` per instance and re-fetches on
`background -> active` only. Not `inactive -> active`: on iOS
`expo-web-browser` parks the app in `inactive` during PKCE sign-in and the
just-in-time consent grant, and both already re-fetch on the identity change
they cause. The listener needs no gating of its own — the existing fetch skips
an app that never requested the `highlights` permission, skips a signed-out
user, and joins whatever request is in flight. The transition rule is a pure
predicate, `shouldRefetchOnForeground`, with a layer-1 truth table.

`BibleReader` accepts a `ref` exposing `refreshHighlights()` (new exported type
`BibleReaderHandle`, React 19 ref-as-prop) for the one trigger the SDK
deliberately does not detect: navigation focus. Detecting it would force
`@react-navigation/native` on every consumer as a peer, so the host calls it
from `useFocusEffect` — the recipe the example app now wires. The handle hands
back core's own `refresh` promise, which never rejects and joins the request in
flight, so a host can await it for a pull-to-refresh spinner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant