feat(core): highlights refetch when the app returns to the foreground (YPE-4491) - #123
Draft
Dustin-Kelley wants to merge 1 commit into
Draft
feat(core): highlights refetch when the app returns to the foreground (YPE-4491)#123Dustin-Kelley wants to merge 1 commit into
Dustin-Kelley wants to merge 1 commit into
Conversation
… (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>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
What problem was I solving
useHighlightsre-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 toAppStateand re-fetches when the app returns to the foreground. Zero config: every direct hook consumer inherits it.packages/ui/src/native/bible-reader.tsx—BibleReaderaccepts arefexposingrefreshHighlights(). New exported typeBibleReaderHandle, re-exported from the package index alongside the verse-selection payload types.apps/example/app/(tabs)/index.tsx— the copyableuseFocusEffectrecipe, ~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
AppStatelistener inuseHighlights, matching the auth provider's own-subscription pattern. Not a shared listener plus a registry: RN handles many listeners fine, and exactly oneuseHighlightsmounts perBibleReader(viauseHighlightPermissionFlow).The transition rule is a pure predicate next to
shouldFetchHighlights:background → activeonly, neverinactive → active. On iOSexpo-web-browserparks the app ininactivefor 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 oninactivewould double-fetch every round-trip. It is also deliberately stricter than the auth provider's unfilteredactivelistener (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" tostate === 'active'goes red.The listener carries no gating of its own.
runFetchalready early-returns for an app that never requested thehighlightspermission, 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 fromAppState.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
Navigation focus is not SDK-detected on purpose. Detecting it would force
@react-navigation/nativeon 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.useImperativeHandlehands back core's ownrefresh, not a wrapper — it never rejects (fetch failures land inerror) and concurrent calls join the one request in flight, so a host can honestlyawaitit for aRefreshControl. Avoidreturn 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
should-refetch-on-foreground.test.ts(new)background→active✓, everything else ✗.use-highlights.test.tsx(extended)background→activeissues a second GET and paints the new highlight;inactive→activeissues none; a foreground refetch while one is in flight joins it.use-highlights.test.tsx(extended)passage_idon all three GETs. Worked already; was unpinned.bible-reader-refresh-handle.test.tsx(new)refreshHighlights; it returns core'srefreshpromise 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
asyncfails the identity test.pnpm typecheck5/5 ·pnpm testcore 423 passed, ui 309 passed + 1 skipped ·pnpm lintclean · prettier clean.Docs
AGENTS.mdgets 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 theBibleReaderHandleJSDoc 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):
Out of scope
VerseOfTheDay/BibleTextViewhighlight surfaces → YPE-4492.clearSelectionSignal— all reused as-is.🤖 Generated with Claude Code