Skip to content

Hoist focus state into a dedicated store with origin at the call site - #267

Draft
imnasnainaec wants to merge 1 commit into
mainfrom
feat/focus-store
Draft

Hoist focus state into a dedicated store with origin at the call site#267
imnasnainaec wants to merge 1 commit into
mainfrom
feat/focus-store

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Implements #263.

focusedTokenRef was useState in Interlinearizer, threaded as props into both views. Six places wrote it, three of them coordinated by hook declaration order, and each consumer reconstructed where a change came from by comparing prop values against refs kept for the purpose.

What changed

src/components/FocusStore.tsx (new) owns the focused token for one mounted book:

  • Origin at the call site. Every write carries a FocusOrigin (seed / strip / list / reseed / request), and the type's doc comment is the one place that says what each origin asks of the strip and of the list. The two readings of "internal" that were explained twice, in prose, in ContinuousView.tsx and useSegmentWindow.ts are now one table with two columns; both files point at it.
  • Precedence as ordered rules, not hook order. The seed and the three reseed effects collapse into one effect that snapshots its inputs and branches in priority order: a claimed focus request first, then a book that can no longer resolve the focus, then a verse the focused segment no longer covers. Both Keep this declared above the focus-request claim below comments are gone, along with the closure-age argument the claim's precedence rested on.
  • Getter and subscription. Focus rides useSyncExternalStore, so the readers that only need the value at event time take it through useFocusGetter() without subscribing, and a focus move re-renders only the views that read it — not Interlinearizer, and not InterlinearizerLoaderInner above it.
  • focusToken keeps its navigation pairing. It still pushes the internal navigation when the token lives in another verse, which is why the provider is mounted inside Interlinearizer (it needs the book indexes and the nav surface together) rather than up in the loader.

Deleted: the onFocusedTokenRefChange prop and its ref mirror, internalFocusedTokenRefRef, and the render-phase block that cleared it. onSelect prop-threading from Interlinearizer goes too — SegmentListView takes it from the store.

Kept, deliberately: lastDisplayUpdateWasInternalRef — something still has to carry the fade-or-glide decision across the fade timer that separates the focus-change effect from the scroll effect; it now carries a declared origin instead of inferring one. The pending-index resync also stays, gated on origin !== 'strip' instead of on an in-flight marker.

Deviation from the issue

The issue proposed a seq counter alongside the value, to distinguish two writes naming the same ref. The store instead drops a write naming the token already focused, which matches the setState bail-out the old code got for free and makes a sequence number dead weight — so there isn't one.

Behavior

Unchanged, including the reseed guard that leaves the strip completely still for an external navigation within the focused segment. That guard and its comment stay exactly where they were in the rule order.

Test plan

  • npm test — 1920 passing, 69 suites, 100% coverage held (statements / branches / functions / lines).
  • npm run lint — clean (eslint, stylelint, tsc, dependency scope).
  • npm run build — clean.
  • New FocusStore.test.tsx covers the store primitives, the three hooks (including each throwing outside a provider), seeding, both actions' navigation decisions, and every resolution rule — including a focus request beating the verse reseed that lands in the same commit, and an unresolvable request warning and falling through to it.
  • New assertion in Interlinearizer.test.tsx that a focus move inside the active verse re-renders Interlinearizer zero times.
  • All 74 existing Interlinearizer tests pass with no changes to any test body — the mocked ContinuousView reads the store instead of props.

Two strip tests were rewritten rather than ported, because both asserted on a state only the prop round-trip could produce — an internal nav in flight that the parent never echoed. They now cover the same invariants through states the store can actually reach: resetting the step count on a focus the strip did not choose, and the window falling back to the live focus while the displayed ref names a token this book lacks.

Not in scope

displayContinuousScroll stays on its upward callback. Interlinearizer's own render reads it to decide whether to mount the strip, so with the provider below it, reading from the store would make Interlinearizer a focus-rate subscriber and cancel the re-render win. It also isn't focus state.

🤖 Generated with Claude Code


This change is Reviewable

The focused word token was `useState` in `Interlinearizer`, threaded as
props into both views, with six writers — three of them coordinated by
hook declaration order — and each consumer reconstructing where a change
came from by comparing prop values against refs kept for the purpose.

Move it to a `FocusProvider` mounted inside `Interlinearizer` below the
book indexes:

- Every write records a `FocusOrigin` at the call site, and one table
  documents what each origin asks of the strip and of the list. The two
  readings of "internal" that were explained twice, in prose, in two
  files are now one table with two columns.
- The seed and the three reseed rules collapse into a single effect that
  branches in priority order, so an outside focus request outranks both
  reseeds by rule rather than by source-file layout. Both "keep this
  declared above" comments are gone, as is the closure-age argument the
  claim depended on.
- Focus rides `useSyncExternalStore`, so an event-time reader takes the
  current value through a getter without subscribing, and a focus move
  re-renders only the views that read it — not `Interlinearizer`, and not
  the loader above it.

Deleted: the `onFocusedTokenRefChange` prop and its ref mirror, the
in-flight internal marker, and the render-phase block that cleared it.
`lastDisplayUpdateWasInternalRef` stays, since something still has to
carry the fade/glide decision across the fade timer, but it now carries a
declared origin instead of inferring one.

Behavior is unchanged, including the reseed guard that leaves the strip
still for an external navigation within the focused segment.

Two strip tests were rewritten rather than ported: both asserted on a
state only the prop round-trip could produce (an internal nav in flight
that the parent never echoed). They now cover the same invariants —
resetting the step count on a focus the strip did not choose, and the
window falling back to the live focus when the displayed ref names a
token this book lacks — through states the store can actually reach.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c8056215-74ac-489e-a2aa-1c18693bb070

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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