Skip to content

feat(ui): host-owned verse actions on BibleReader - #307

Open
cameronapak wants to merge 6 commits into
mainfrom
ype-2894-add-highlights-to-the-react-native-expo-sdk
Open

feat(ui): host-owned verse actions on BibleReader#307
cameronapak wants to merge 6 commits into
mainfrom
ype-2894-add-highlights-to-the-react-native-expo-sdk

Conversation

@cameronapak

@cameronapak cameronapak commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

YPE-2894 | Artifacts | Task

What problems was I solving

BibleReader always rendered its own VerseActionPopover over a selection. That is right for a web host, but wrong for a React Native / Expo host that presents a native bottom sheet for verse actions: the in-WebView popover stacks on top of the native sheet, so the user sees two competing action surfaces.

A host can't just hide the popover with CSS, because three things were locked inside it:

  1. No opt-out. There was no way to render the reader's selection behavior without its action UI.
  2. No way to clear the selection from outside. Only the popover's own close path cleared the selection. Turn the popover off and the selection has no exit. An imperative ref handle doesn't work here — Expo DOM components only accept serializable props.
  3. No display data on the selection payload. onVerseSelect gave USFM codes (HEB), not a label a host can render (Hebrews 11:4), and the Copy / Share payload only existed inside the popover's button handlers.

Shipped, a host can own the entire verse-action surface while the reader keeps doing selection, painting, and payload construction. Every default is unchanged — an existing consumer that passes nothing sees identical behavior, which is what the passing UI test suite holds in place.

What user-facing changes did I ship

No breaking changes. verseActions defaults to 'popover', clearSelectionSignal is inert when unset, and the two new payload fields are additive.

How I implemented it

Frontend Changes

1. Opting out of the popoverbible-reader.tsx R1051

verseActions threads from RootProps through BibleReaderContextType into Content, where the <VerseActionPopover> is wrapped in {verseActions !== 'none' && ...}. It does not mount at all rather than render hidden — two action surfaces would stack. Nothing else in Content branches on it: selection, painting, and onVerseSelect are on the same path in both modes.

One consequence worth reading before you use 'none': handleHighlight / handleClearHighlight are wired only into the popover, so not mounting it means onHighlightApply / onHighlightRemove never fire in this mode. That's intended — a host in 'none' mode owns highlights and paints by updating that prop — but the first version of this PR documented the opposite. Caught by @Dustin-Kelley in review and corrected in e03fdf6 across the verseActions TSDoc, the two callback TSDocs, and the changeset, with a test that holds the contract.

2. Making the payload useful to a hostbuildVerseSelection R805

buildVerseSelection now also produces reference (via the existing buildVerseReference, with versionAbbreviation: '') and shareData. reference and shareData.reference deliberately differ: copied text keeps the version (Hebrews 11:4 BSB), a UI label does not (Hebrews 11:4). Both are documented in TSDoc with a "do not 'fix' one to match the other" note.

This required buildSelectionShareData to take verses as a parameter instead of reading selectedVerses state. buildVerseSelection runs inside the click handler that sets the new selection, so reading state there would have built the payload for the previous selection. The two existing callers (handleCopy / handleShare) pass selectedVerses explicitly, so their behavior is unchanged.

BibleReaderHighlightIntent was BibleReaderVerseSelection & { color }; it's now Omit<BibleReaderVerseSelection, 'reference' | 'shareData'> & { color }. An intent is addressed to the data layer, which needs identity and color only — no reason to widen it.

3. Host-driven clearingclearSelectionSignal effect R797

An effect on clearSelectionSignal calls the existing closeAndClearSelection — the same path an outside click runs, so there's no second clear implementation to drift. Two details:

  • The handler is read through a ref, so the effect depends on the signal alone (the handler is re-created every render).
  • lastClearSelectionSignalRef seeds from the mount value, so mounting is never a clear and a re-render with an unchanged signal is a no-op.

closeAndClearSelection was also extended to emit reference: '' / shareData: null alongside verses: [].

4. Stale-metadata re-emit (Greptile P1 fix)metadata effect R842

reference and shareData are a snapshot of useBooks / useVersion, which can resolve after a verse is selected. In that window the payload carries the raw USFM code (HEB 11:4) and no version abbreviation — and with verseActions="none" nothing else would ever refresh it, so a host would display the fallback until the user re-selected.

A second effect watches bookData?.title and version?.localized_abbreviation and re-emits the live selection with the same verses and refreshed fields when either lands. Guards:

  • Both refs seed from mount values, so the first effect run is never an emit.
  • An empty-selection guard means no re-emit when nothing is selected.
  • The navigation-clear effect is declared first, so it runs first — a book/version change clears the selection before this effect could re-emit a dead one.

The consequence for hosts is documented on onVerseSelect and in the changeset: treat the payload as the current state of the selection, not as one event per user action.

Tests

bible-reader-controlled.test.tsx — 17 new cases in three describes:

  • BibleReader verseActions — popover renders by default, renders for an explicit 'popover', renders nothing for 'none' while selection still works, and emits no highlight intents in 'none' mode.
  • BibleReader selection payload - reference and shareData — localized reference without the version abbreviation, USFM fallback before useBooks resolves, re-emit when the book title resolves, re-emit when the version abbreviation resolves, no re-emit with no selection live, no re-emit on a settled re-render (the default path stays quiet), shareData matching what onCopy produces, and '' / null on clear.
  • BibleReader clearSelectionSignal — clears and emits verses: [], also closes the built-in popover, does not clear on mount, does not clear on an unchanged re-render, inert when never passed.

Deviations from the plan

No plan file found in the task directory — this task carried only ticket.md, so there is no plan-vs-implementation delta to report.

Worth flagging for reviewers who saw the earlier state of this PR, two follow-up commits landed from review:

  • 86effb6 resolves the Greptile P1 "Selection metadata remains stale" comment — section 4 above, plus its four tests.
  • e03fdf6 corrects the verseActions="none" highlight-intent contract after @Dustin-Kelley caught the docs claiming intents still fire when the popover (their only trigger) isn't mounted. Docs and changeset only, plus one test. No behavior change.

How to verify it

Manual Testing

  • Existing consumer, no new props: verse selection and the popover behave exactly as before (Copy, Share, highlight, outside-click to clear).
  • verseActions="none": verses still select and paint, no popover appears anywhere, onVerseSelect fires with populated reference and shareData.
  • With verseActions="none", increment clearSelectionSignal from host UI: selection clears and onVerseSelect fires with verses: [], reference: '', shareData: null.
  • Mount with a non-zero clearSelectionSignal: nothing is cleared on mount.
  • Select a verse on a cold load (before books/version resolve): the first payload shows the USFM code, then a second onVerseSelect arrives with the localized Hebrews 11:4 and full shareData.
  • Confirm reference has no version abbreviation while shareData.reference does.

Automated Tests

git fetch origin ype-2894-add-highlights-to-the-react-native-expo-sdk
git checkout ype-2894-add-highlights-to-the-react-native-expo-sdk
pnpm install
pnpm typecheck
pnpm --filter @youversion/platform-react-ui test

Current state on this branch: 377 tests passing across 28 files in @youversion/platform-react-ui, and all 8 CI checks green on e03fdf6.

Description for the changelog

BibleReader can now hand verse actions to the host via verseActions="none", with clearSelectionSignal for outside-in clearing and reference / shareData on the onVerseSelect payload. All defaults unchanged.

Greptile Summary

Adds host-owned verse-action support to BibleReader.

  • Introduces verseActions="none" to suppress the built-in action popover while preserving verse selection.
  • Adds clearSelectionSignal for host-driven selection clearing.
  • Extends selection payloads with localized reference and reusable shareData.
  • Re-emits active selections when delayed book or version metadata resolves.
  • Documents the public API and adds controlled-reader coverage for the new behavior.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported stale-selection-metadata failure is resolved: metadata changes rebuild and re-emit a live selection, while navigation and host-driven clearing empty the selection ref before that refresh effect can emit.

Important Files Changed

Filename Overview
packages/ui/src/components/bible-reader.tsx Adds the host-owned action mode, external clear signal, enriched selection payload, and guarded metadata-refresh re-emission.
packages/ui/src/components/bible-reader-controlled.test.tsx Adds focused tests for action-mode behavior, payload construction and refresh, highlight callback semantics, and external clearing.
.changeset/bible-reader-headless-verse-actions.md Documents the additive public API and metadata re-emission contract for the UI package release.

Sequence Diagram

sequenceDiagram
  participant Host
  participant Reader as BibleReader
  participant Hooks as useBooks/useVersion
  participant Actions as Host action UI
  Host->>Reader: "Render verseActions="none""
  Reader->>Host: onVerseSelect(fallback reference, shareData)
  Host->>Actions: Present native actions
  Hooks-->>Reader: Localized book/version metadata resolves
  Reader->>Host: Re-emit current selection with refreshed metadata
  Host->>Reader: Increment clearSelectionSignal
  Reader->>Host: onVerseSelect(verses: [], reference: "", shareData: null)
Loading

Reviews (5): Last reviewed commit: "test(ui): make the verseActions="none" i..." | Re-trigger Greptile

Three additive, non-breaking changes to BibleReader.Root so a host can own
the verse-action UI. Every default is unchanged.

- verseActions?: 'popover' | 'none' gates the built-in VerseActionPopover.
- clearSelectionSignal?: number clears the selection from outside; a counter
  rather than a ref handle because Expo DOM props must be serializable.
- BibleReaderVerseSelection gains reference and shareData; the highlight
  intent payload stays byte-identical via Omit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6009f21

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-react-ui Minor
vite-react Patch
@youversion/platform-core Minor
@youversion/platform-react-hooks Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/ui/src/components/bible-reader.tsx
@cameronapak cameronapak self-assigned this Jul 29, 2026
cameronapak added a commit to youversion/platform-sdk-reactnative-expo that referenced this pull request Jul 29, 2026
Verse actions move out of the Web SDK's in-WebView popover and into a
native bottom sheet, matching Swift and Kotlin. The DOM component gets
`verseActions="none"` on every platform but web; native holds the
committed selection from `onVerseSelect` and clears it back over the
bridge with `clearSelectionSignal`, a counter (only serializable props
cross the Expo DOM bridge, so a ref handle is not an option).

- `native/bible-verse-action-sheet.tsx` — presentational, not exported.
  Horizontally scrolling swatch tray under an SVG gradient fade at each
  end, with Copy and Share pinned outside the scroll area.
- `lib/verse-action-swatches.ts` — port of the Web SDK's shipped ANY
  rule, layer-1 tested.
- `NativeSheet` gains `modal`. The action sheet is non-modal so the
  passage behind stays tappable while a selection is built; that drops
  the backdrop, so sheets now draw an upward `boxShadow`
  (`SHEET_TOP_SHADOW`) to separate the top edge.
- `onCopy` / `onShare` no longer cross the bridge — `shareData` rides in
  on `onVerseSelect` (Web SDK 2.5.0). The consumer-override-then-SDK-
  fallback contract is unchanged.

Also exempts `@youversion/*` from the pnpm release-age cooldown, so a
Web SDK enabling change is consumable the day it publishes.

Requires @youversion/platform-react-ui 2.5.0, which is not yet
published — pnpm-lock.yaml still resolves 2.4.0 and must be regenerated
once youversion/platform-sdk-react#307 ships.

See docs/adr/0015-native-verse-action-sheet.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lves

Greptile P1 on #307: `reference` and `shareData` were a snapshot of
`useBooks` / `useVersion` taken when the selection was made. A selection
made before that metadata resolved left the host holding the USFM book
code and a version-less copy/share payload until the user reselected —
visible to any host on `verseActions="none"` rendering its own UI.

`Content` now re-emits the live selection when the localized book title
or version abbreviation changes. Both refs seed from the mount values, so
the first effect run is never an emit, and the empty-selection guard
keeps navigation (which clears first) from re-emitting a dead selection.

`onVerseSelect` therefore can fire twice for one user action inside that
race window, with the same `verses`. Documented on the prop, the
`reference` field, and the changeset: the payload is the current state of
the selection, not one event per action. Not gated on
`verseActions="none"` on purpose — payload freshness that varies by mode
is a worse trap than an extra call.

Adds 4 cases: a re-emit for each of the two metadata sources (both fail
without the fix), plus negative controls for no live selection and for a
settled-metadata re-render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cameronapak

Copy link
Copy Markdown
Collaborator Author

Hey @Dustin-Kelley / @jaredhightower-youversion, this is ready for review.

It makes BibleReader's verse actions host-owned so a React Native / Expo host can show its own native action sheet instead of the built-in web popover stacking on top of it.

Or... explain like I'm 5:

BibleReader is a piece of the app that shows Bible verses on screen. When you select a verse, it pops up a little menu of actions (Copy, Share, Highlight).

The problem: that menu was baked in. If a phone app wants to show its own menu instead (a nice native one), the baked-in one still shows up on top — so the user sees two menus fighting each other.

This PR adds a switch that says: "Hey BibleReader, keep doing all the verse-selecting work, but let me show the action menu myself." It also hands the app a couple of helpful things — a clean label like "Hebrews 11:4" and the ready-to-share text — so the app's own menu has what it needs.

Best part: if you don't touch any of the new switches, everything works exactly like before. No surprises for anyone already using it.

Comment thread packages/ui/src/components/bible-reader.tsx Outdated
Dustin-Kelley
Dustin-Kelley previously approved these changes Jul 29, 2026

@Dustin-Kelley Dustin-Kelley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude found that one docs issue but it's not blocking.

The built-in popover is the only trigger for handleHighlight /
handleClearHighlight, so verseActions="none" - which stops it mounting -
also stops onHighlightApply / onHighlightRemove from ever firing. The
prop docs and the changeset claimed the opposite, which would have sent
a host down a dead path: set "none", pass onHighlightApply, render its
own swatches, and never hear a tap. A host in this mode owns `highlights`
and paints by updating that prop.

Docs only, plus a test that holds the contract honest. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cameronapak cameronapak changed the title feat(ui): host-owned verse actions on BibleReader (YPE-2894) feat(ui): host-owned verse actions on BibleReader Jul 30, 2026
cameronapak and others added 2 commits July 30, 2026 10:47
The assertion was that neither highlight callback fires after selecting
two verses - but selecting a verse never emits an intent in either mode,
a swatch has to be clicked. The test passed with verseActions="popover"
too, so it locked nothing.

Now a differential: the popover arm clicks Apply and Clear and proves
both callbacks fire, then the same props and script under 'none' assert
they cannot. Verified by mutation - removing the `verseActions !== 'none'`
guard fails the test.

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.

2 participants