Skip to content

Highlights auth/write flow: xstate statechart + sign-in dialog (2/4)#285

Merged
cameronapak merged 10 commits into
mainfrom
claude/ype-1034-pr2-highlight-auth-flow
Jul 21, 2026
Merged

Highlights auth/write flow: xstate statechart + sign-in dialog (2/4)#285
cameronapak merged 10 commits into
mainfrom
claude/ype-1034-pr2-highlight-auth-flow

Conversation

@cameronapak

@cameronapak cameronapak commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

The full highlights interaction flow — auth, data-exchange permission, writes, resume — is an xstate v5 statechart, with a SignInDialog for signed-out color taps. This presents the machine in its final reviewed form; the staging fixes from old #287 are embedded, so that PR stays closed.

Stack: 1/4 #2832/4 (this PR) → 3/4 #286 → 4/4 #294 · restored from #288

Changes

  1. Tapping a highlight color while signed out opens SignInDialog (ported from Swift SignInWithYouVersionView as a dialog, not a bottom sheet) before OAuth; appName and signInPromptMessage are new optional statics on YouVersionPlatformConfiguration.
  2. bible-reader-highlights-machine.ts (xstate v5) owns the whole flow — fetch, permission, write batches, pending-write resume, sign-out — and useBibleReaderHighlights becomes a thin adapter; mermaid statechart at docs/highlight-flow-statechart.md.
  3. Highlight writes settle per sub-write and refetch on failure, so one failed batch can't strand optimistic state (absorbed from fix(highlights): staging-test fixes — dialog button, overlay reconciliation, per-verse delete (YPE-1034, PR 4) #287). Permission-lost writes stash their intents as a LIST (verse-level last-wins), so multiple failed batches all survive the re-grant.
  4. Deleted highlights can't resurrect from stale replica reads — remove-overlay entries persist until scope change, sign-out, or a newer write.
  5. Granted-permissions cache is user-scoped {userId, permissions} in localStorage.
  6. Active-swatch remove affordance temporarily renders an X; the checkmark lands in 3/4 (one code comment references it early — intentional).
  7. Out of scope by design (land in 4/4 Final wiring of all things highlights API and auth-guard, with fix for highlight not deleting immediately (visually) #294): initiating-user grant binding and highlightsInteractive gating. (xstate is exact-pinned to 5.32.5 in this PR.)

Start here: change 2 — the statechart is the review unit; the mermaid doc mirrors its states.

Test plan

Greptile Summary

This PR wires the full highlights auth flow — signed-out color tap → sign-in dialog → OAuth redirect → pending resume; signed-in without permission → just-in-time data-exchange flow — into an xstate v5 statechart (bible-reader-highlights-machine.ts), replacing the hand-rolled effect orchestration. useBibleReaderHighlights becomes a thin adapter; SignInDialog is ported from the Swift SDK; and fixes absorbed from staging (#287, #288) are embedded here.

  • Statechart (bible-reader-highlights-machine.ts): parallel flow + writer regions handle the auth dialogs, serialized write queue, per-verse ownership tokens, and the vapor fix (remove overlay entries never retire, preventing stale-replica ghosts).
  • Pending highlight stash (pending-highlight.ts): upgraded from single-slot to a list with verse-level last-wins merge so multiple concurrent 401-failing writes each survive the re-grant.
  • Permission cache (YouVersionPlatformConfiguration): user-scoped {userId, permissions} in localStorage; seeded from both sign-in and data-exchange callbacks; invalidated on 401/403; cleared on sign-out.
  • useHighlights refetch removal: createHighlight/deleteHighlight no longer auto-refetch; callers coalesce and call refetch() once after the batch settles.

Confidence Score: 5/5

Safe to merge. The statechart, permission cache, and pending-highlight list are all correctly implemented; previous review feedback is fully incorporated.

All major edge cases are handled and tested: concurrent 401-failing writes both survive the re-grant (verse-level last-wins merge), stale-replica ghost highlights are suppressed (remove overlay entries never retire), scope changes synchronously clear ownership tokens so stale writes cannot pollute new-scope state, and the permission cache is correctly user-scoped. The three previously flagged bugs have verified fixes with new direct-machine tests. Deferred items are explicitly documented with follow-up tracking.

No files require special attention. bible-reader-highlights-machine.ts is the review unit per the PR description and holds up well under scrutiny.

Important Files Changed

Filename Overview
packages/ui/src/components/bible-reader-highlights-machine.ts New xstate v5 statechart: parallel flow/writer regions, per-verse ownership tokens, vapor fix, pending-resume logic. Logic is correct and well-tested; deferred items (token-expiry 401 misrouting, resume-write failure handling) are clearly documented.
packages/ui/src/lib/pending-highlight.ts Upgraded from single-slot to a list with per-entry TTL and verse-level last-wins merge. appendPendingHighlight correctly handles concurrent 401-failing writes; expiry and stale-cleanup logic are sound.
packages/core/src/YouVersionPlatformConfiguration.ts New user-scoped granted-permissions cache ({userId, permissions} in localStorage), appName/signInPromptMessage statics. Cache is correctly scoped, cleared on sign-out, and guarded against user-mismatch reads.
packages/ui/src/components/use-bible-reader-highlights.ts Reduced to a thin adapter: spawns the machine once, keeps a latest-ref bag for services, feeds AUTH_CHANGED/SCOPE_CHANGED/HIGHLIGHTS_UPDATED events, and derives dialog state via useSelector. Clean and correct.
packages/core/src/data-exchange.ts New data-exchange client: mints short-lived token, builds consent URL, parses callback params, handles granted return (saves permissions, strips URL). Logic mirrors the Swift SDK contract correctly.
packages/hooks/src/useHighlightAuthActions.ts New hook exposing hasHighlightsPermission, invalidateHighlightsPermission, consumeDataExchangeReturn, startSignInForHighlights, startDataExchangeForHighlights. Reads context defensively so it never throws when no auth provider is mounted.
packages/hooks/src/useHighlights.ts Removed auto-refetch from createHighlight/deleteHighlight; callers now coalesce and call refetch() once after a write batch settles. Breaking change documented in JSDoc; changeset acknowledges it.
packages/ui/src/components/sign-in-dialog.tsx New SignInDialog component ported from Swift SDK. Presentational only, all i18n copy routed through t(), promptMessage hidden when unset. Callbacks delegated to parent.
packages/ui/src/components/bible-reader-highlights-machine.test.ts New direct machine tests covering writeIntent lifecycle, scope-change clearing, and the multi-401 batch scenario (both intents survive and re-apply).
packages/core/src/Users.ts Sign-in callback now seeds the granted-permissions cache from granted_permissions URL params. saveUserInfo is correctly called before saveGrantedPermissions so the user-scope guard is satisfied at seed time.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User
    participant BR as BibleReader
    participant M as HighlightsMachine
    participant SS as SessionStorage
    participant LS as LocalStorage
    participant API as YouVersion API

    Note over M: boot to enabled (flow + writer in parallel)

    U->>BR: tap color (signed out)
    BR->>M: TAP_COLOR
    M->>SS: stashPendingHighlight
    M-->>BR: "lastTapOutcome = flow"
    Note over M: flow to signInDialog

    U->>BR: confirm sign-in
    BR->>M: CONFIRM_SIGN_IN
    M->>API: startSignInForHighlights (full-page redirect)

    API-->>BR: redirect back with code + granted_permissions
    BR->>LS: saveUserInfo then saveGrantedPermissions
    Note over M: new page load resuming

    M->>SS: consumeDataExchangeReturn (null)
    Note over M: pending + authed + hasPermission to applyPendingHighlight
    M->>SS: readPendingHighlights + clearPendingHighlight
    M->>API: createHighlight
    API-->>M: 200 OK
    M->>API: refetch
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User
    participant BR as BibleReader
    participant M as HighlightsMachine
    participant SS as SessionStorage
    participant LS as LocalStorage
    participant API as YouVersion API

    Note over M: boot to enabled (flow + writer in parallel)

    U->>BR: tap color (signed out)
    BR->>M: TAP_COLOR
    M->>SS: stashPendingHighlight
    M-->>BR: "lastTapOutcome = flow"
    Note over M: flow to signInDialog

    U->>BR: confirm sign-in
    BR->>M: CONFIRM_SIGN_IN
    M->>API: startSignInForHighlights (full-page redirect)

    API-->>BR: redirect back with code + granted_permissions
    BR->>LS: saveUserInfo then saveGrantedPermissions
    Note over M: new page load resuming

    M->>SS: consumeDataExchangeReturn (null)
    Note over M: pending + authed + hasPermission to applyPendingHighlight
    M->>SS: readPendingHighlights + clearPendingHighlight
    M->>API: createHighlight
    API-->>M: 200 OK
    M->>API: refetch
Loading

Reviews (8): Last reviewed commit: "docs(hooks): contract-note the no-auto-r..." | Re-trigger Greptile

@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 353d526

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-core Minor
@youversion/platform-react-hooks Minor
@youversion/platform-react-ui Minor
vite-react Patch

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

@cameronapak

Copy link
Copy Markdown
Collaborator Author

Superseded by #288, which consolidates the full YPE-1034 stack into a single PR rebased onto main.

@cameronapak cameronapak reopened this Jul 20, 2026
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr1-highlights-api branch from 5b7d334 to 7f66a99 Compare July 20, 2026 20:15
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr2-highlight-auth-flow branch from f275bbf to 879e04e Compare July 20, 2026 20:15
@cameronapak cameronapak changed the title feat(ui): highlight auth flow state machine (YPE-1034, PR 2/3) Highlights auth/write flow: xstate statechart + sign-in dialog (2/4) Jul 20, 2026
@cameronapak
cameronapak marked this pull request as ready for review July 20, 2026 20:18
Comment thread packages/ui/src/components/bible-reader.tsx
Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts
Comment thread packages/core/src/SignInWithYouVersionResult.ts Outdated
Comment thread packages/ui/src/i18n/locales/en.json
Comment thread packages/core/src/YouVersionPlatformConfiguration.ts
Base automatically changed from claude/ype-1034-pr1-highlights-api to main July 21, 2026 15:49
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr2-highlight-auth-flow branch 2 times, most recently from 75bdf08 to d26b1f4 Compare July 21, 2026 16:17
@cameronapak cameronapak self-assigned this Jul 21, 2026
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr2-highlight-auth-flow branch from d26b1f4 to 10f47c6 Compare July 21, 2026 16:43
cameronapak and others added 6 commits July 21, 2026 11:55
Turn a color tap without a session or the `highlights` permission into an
applied highlight across the two auth paths, behind the internal
HIGHLIGHTS_LIVE flag.

Core: DataExchangeClient (POST /data-exchange/token, Zod-validated), the
hosted-grant URL builder + callback parser/handler, and an optimistic
permission cache on YouVersionPlatformConfiguration seeded from
`granted_permissions` on the sign-in and data-exchange callbacks (server
401/403 invalidates it). SignInWithYouVersionResult gains `permissions`.

Hooks: useHighlightAuthActions exposes one-fell-swoop sign-in (requesting
`highlights`), the just-in-time data-exchange redirect, permission
reads/invalidation, and the data-exchange return handler.

UI: useBibleReaderHighlights runs the state machine — pending highlights
persist to sessionStorage (~10-min expiry) across the redirect round-trip and
apply on a granted return; a permission confirm dialog (copy matched to the
native SDK, en/fr/es) gates the grant. Write failures route by status
(401/403 re-prompts and keeps pending; 5xx/network reverts and discards).
Apply/remove writes are serialized through a FIFO queue with per-verse
ownership, closing the two concurrency windows PR1 documented. Copy/share-only
behavior with no auth provider is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on hydrates

The resume effect consumed the data-exchange return status into a local, but
bailed on `!isAuthenticated` first. The shipped YouVersionAuthProvider
hydrates userInfo asynchronously, so the first effect run after a redirect
return is always unauthenticated: the status was consumed and lost, and when
the session flipped the cancel/failure branch was skipped — the pending
highlight survived a decline and the just-declined dialog re-opened.

Discard the pending highlight at consume time for any non-granted return,
before the auth gate: it runs exactly once and needs no session (a decline
kills the intent regardless of who signs in).

Tests: the cancel-return test previously set signedIn=true synchronously — a
timing the real provider never produces — which hid the bug. It now mounts
signed out and flips the session after mount (failed against the old code,
passes now), plus a failure-status variant with the same timing and the
granted-return test aligned for realism.

Also document the three review-deferred follow-ups (expired-token 401
misrouting, resume-write failure handling, remove-failure re-prompt) at their
code sites and in the changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ui: replace hardcoded 'This app' sign-in fallback with i18n key
  `signInAppNameFallback` (en/es/fr), computed via `t()` in BibleReader.
- ui: settle/scope-change now clean up the highlights machine's per-verse
  `writeIntent` claims — `settleWrite` releases a verse's claim when the
  settling op still owns it, and `resetForScopeChange` clears `writeIntent`
  so a stale-scope write can't pollute the new scope's reconcile/overlay
  under a colliding verse number. Adds direct machine unit tests.
- core: `SignInWithYouVersionResult.permissions` is now `readonly`; grants
  are computed before construction and passed through the constructor in
  Users.handleAuthCallback instead of post-construction mutation.
- core: correct the stale `setGrantedPermissions` docstring (it is a
  wholesale replace; the data-exchange callback uses saveGrantedPermissions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DataExchangeTokenResponseSchema was defined inline in data-exchange.ts;
core convention keeps Zod schemas in schemas/ as the single source of truth.
No schema-shape or public entry-point export changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Pin xstate to exact 5.32.5 (supply-chain decision); dedupes an orphaned
  esbuild@0.25.11 in the lockfile.
- Add non-mutating peekPendingHighlight and use it in the machine guards so
  xstate guards stay pure; the clear-on-expiry side effect stays in the
  consume/action paths.
- Export scopesEqual from the machine and drop the duplicate scopesMatch copy
  in the adapter.
- Extract a shared claimVerses helper for the three write-claim assign blocks.
- Narrow consumeDataExchangeReturn status to the real union and read the write
  actor's DoneActorEvent instead of an ad-hoc { output } shape.
- Initialize the services ref with a real value (latest-ref pattern), removing
  the null-as-unknown double cast.
- Extract shared Radix dialog chrome into components/ui/dialog.tsx and refactor
  both dialogs onto it (byte-equivalent markup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr2-highlight-auth-flow branch from 10f47c6 to 65a2ab2 Compare July 21, 2026 17:07
Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts
Comment thread packages/ui/src/components/ui/button.tsx
Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts
Comment thread packages/ui/src/lib/pending-highlight.ts
…ose permission

Make the pending-highlight sessionStorage stash multi-entry so two queued
optimistic writes that each fail 401/403 both survive the re-grant. A
single-slot stash let the second permission-denied settle overwrite the
first, silently losing the earlier tap's highlight after the data-exchange
redirect.

- stashPendingHighlight replaces the whole list (fresh tap supersedes);
  appendPendingHighlight merges with verse-level last-wins.
- peekPendingHighlights (pure) / readPendingHighlights (clears stale) return
  live entries; per-entry timestamp drives per-entry expiry.
- settleWrite appends on permission-denied apply; applyPendingHighlight
  resumes every entry, first-to-last, each to its own scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts Outdated
…tash

A user apply reaching the write queue via startApplyWrite never stashes an
intent of its own, so the network/5xx branch in settleWrite had nothing to
drop. The only entries in the stash mid-writing are ones a sibling batch
appended via the 401 path moments earlier — clearing them there wiped a
permission-lost intent that must survive the re-grant, so nothing resumed.

Remove the clearPendingHighlight() call from the non-permission failure path;
the decline/failure consume, resume-consume, redirect-failure catches, and
dialog cancel/decline actions remain the intended owners of the clear.

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

Copy link
Copy Markdown
Collaborator Author

thank you sir greppy file-27433cb0f2869f1a96ebc8a71f277c30

@jaredhightower-youversion jaredhightower-youversion 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.

Ponytail pass (over-engineering only) — all non-blocking. Correctness was already well covered by the existing threads.

Comment thread packages/core/src/SignInWithYouVersionResult.ts Outdated
Comment thread packages/core/src/YouVersionPlatformConfiguration.ts Outdated
Comment thread packages/core/src/data-exchange.ts
Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts Outdated
Comment thread packages/ui/src/components/use-bible-reader-highlights.ts
Comment thread packages/core/src/permissions.ts Outdated
Comment thread packages/ui/src/lib/pending-highlight.ts Outdated
Comment thread docs/highlight-flow-statechart.md
Comment thread .changeset/highlight-auth-flow.md Outdated
Comment thread .changeset/xstate-highlights-flow.md Outdated
Dustin-Kelley
Dustin-Kelley previously approved these changes Jul 21, 2026
Addresses the 18 accepted items from the over-engineering review pass:
drop the unused SignInWithYouVersionResult.permissions field and
setGrantedPermissions (permission cache is the single source of truth),
share formatPassageId from usfm-ranges, inline DialogOverlay, simplify
pending-highlight merge and parseGrantedPermissions, unsort updateToken
payload, module-level consts in useHighlightAuthActions, vi.waitFor and
real-writer seeding in tests, drop redundant restores/clears/duplicate
tests, and trim internal tracking from docs and changesets.

Declined as stack-owned: getAuthToken dedupe and serverColorsEqual
removal (both superseded by PR 294's hardening commits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/hooks/src/useHighlights.ts

@jaredhightower-youversion jaredhightower-youversion 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.

Approved but there is one small comment from greptile.

…to minor

useHighlights.createHighlight/deleteHighlight lost their per-call
auto-refetch in this PR (batch writes coalesce to one GET). The hook is
public API and 2.3.0 ships the old behavior, so document the new
contract as JSDoc on the return type and reclassify the hooks changeset
as minor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cameronapak
cameronapak merged commit 337ae5c into main Jul 21, 2026
8 checks passed
@cameronapak
cameronapak deleted the claude/ype-1034-pr2-highlight-auth-flow branch July 21, 2026 19:34
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.

3 participants