platform: rebuild ScriptureReferencePlugin as a navigation state machine#499
Conversation
Navigation through the editor was intermittently unreliable: verses reset
to 0 (e.g. 78:9 -> 78:0 on scroll-group reattach or a view-option toggle),
navigations landed somewhere other than their target during rapid
check-list movement, and scripture references stuck or desynced. The root
causes were structural: plugin state smeared across interacting one-shot
boolean refs, and emissions built from sources with different freshness
(prop book paired with document chapter/verse and vice versa).
The plugin is rebuilt around an explicit machine documented in the file
header:
- phase "idle" | "navigating": while an external scrRef change (or a
document replacement / book correction) is being applied, the plugin
emits NOTHING; the window closes on real user input (pointerdown,
keydown, beforeinput) or when one of its own reports echoes back.
- pendingEchoes FIFO: emissions not yet seen back as the prop; a prop
change matching one is our own echo (consumed through the match - React
batching means only the last of several reports returns) and never
moves the caret.
- One emission gate (report()) with exactly two shapes: position reports
(a single document snapshot) and the book-correction contract
({...scrRef, book}) for mismatched documents.
- Cursor placement is book-gated (prop-driven placement never moves the
caret inside a different book's stale document) and never fires for
pure-created BookNodes while a document is loaded (pastes/undo must not
teleport the caret).
The header documents the six races (R1-R6), eight invariants (I1-I8) each
backed by a named test, the deliberate trade-offs, and safe/unsafe change
lists. Hardening from review: malformed verse ranges ("3-2") no longer
throw inside the selection listener; empty chapter numbers resolve as
unaddressable instead of emitting NaN; position resolution is gated on
phase before running.
Testing: characterization tests pinning legacy behavior were written
against the old implementation first and pass unmodified; 35 tests total
including regression pins for every race (reattach clobber, rapid
re-target hijack, stale-book echo, superseded document arrival, mid-nav
mutations, read-only click swallowing, idle same-book reload, batched-host
echoes, verse-0 as data). jsdom fidelity shims (Range.getBoundingClientRect,
selection-preserving focus()) are documented in the test file. Suite run
15+ times consecutively with zero flakes; whole-monorepo test/lint/
typecheck/format green.
Live-verified in Platform.Bible via yalc: scroll-group reattach holds
78:9; NUM 1 -> previous chapter lands and stays on LEV 27:1; rapid
check-list navigation (including A->B->A and verse-0 items) lands
correctly; view-option toggles no longer reset the verse; read-only
surfaces report the first click after navigation; in-editor clicks, book
jumps, and typing across verse boundaries behave.
AI-assisted (Claude Code)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGsAqgwChpCeCc8KW4o3HF
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
katherinejensen00
left a comment
There was a problem hiding this comment.
This has been a real problem. Thank you for fixing it! Just a couple of nitpicky documentation improvements to consider.
@katherinejensen00 reviewed 2 files and all commit messages, and made 4 comments.
Reviewable status: all files reviewed, 3 unresolved discussions (waiting on lyonsil).
packages/platform/src/editor/ScriptureReferencePlugin.tsx line 272 at r1 (raw file):
if (batch.hasCreated && batch.hasDestroyed) { // REPLACEMENT (LoadStatePlugin's setEditorState swap, in-editor book jump): the swap's own
Thank you for including such clear yet succinct documentation. The comments make it a lot easier to follow what is happening in this file!
packages/platform/src/editor/ScriptureReferencePlugin.test.tsx line 236 at r1 (raw file):
} }); // Clear hasCursorMovedRef (set by initial "move cursor to verse start" effect) so our dispatch runs BCV logic.
NIT Documentation Cleanup
Summary: This comment explains the setup in terms of hasCursorMovedRef, a ref this PR deletes.
hasCursorMovedRef existed only in the old implementation. The line "Clear hasCursorMovedRef (set by initial 'move cursor to verse start' effect) so our dispatch runs BCV logic" describes a mechanism that no longer exists — and the characterization test 30 lines below (line 263) already states the correct new-code reason: "a no-op report (position == scrRef) in the new code." Update line 236 to match, so the two comments stop contradicting each other.
packages/platform/src/editor/ScriptureReferencePlugin.test.tsx line 527 at r1 (raw file):
const selection = $getSelection(); const startNode = getSelectionStartNodeForTest(selection); expect(startNode?.getTextContent()).not.toBe("second verse text ");
NIT
Summary: expect(startNode?.getTextContent()).not.toBe("second verse text ") is nearly always true and pins little.
In "applies a navigation to verse 0," the caret started on secondVerseTextNode and the assertion only checks it left that text — almost any landing (including a wrong one) passes. Prefer asserting the caret is in the chapter-top region you actually expect. Note: the visually-identical assertion at line 577 (placement-gate test) is fine as-is — the swapped-in EXO fixture also contains "second verse text ", so there it precisely targets the known wrong-placement node, as the line-576 comment explains. Only line 527 is weak.
…ptureReferencePlugin - Carry the host's versificationStr on position reports so a report never strips it (the document states no versification); pinned by test - Refuse to resolve a position whose verse number parses to NaN, mirroring the chapter guard (I5); pinned by test - Hoist the missing-chapter bail in $moveCursorToVerseStart above the chapter-scoping walks it made redundant - Replace a stale test comment referencing the deleted hasCursorMovedRef and strengthen the verse-0 landing assertion to the exact expected position (review feedback from @katherinejensen00) - Document three deliberate choices: any keydown closes the navigation window, schedulePlacement is not book-gated at fire time, and the BookNode/verse mutation-listener registration order is load-bearing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
katherinejensen00
left a comment
There was a problem hiding this comment.
Looks great. Thanks, Matt!
@katherinejensen00 reviewed 2 files and all commit messages, made 1 comment, and resolved 3 discussions.
Reviewable status:complete! all files reviewed, all discussions resolved.
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil made 3 comments.
Reviewable status:complete! all files reviewed, all discussions resolved.
packages/platform/src/editor/ScriptureReferencePlugin.tsx line 272 at r1 (raw file):
Previously, katherinejensen00 wrote…
Thank you for including such clear yet succinct documentation. The comments make it a lot easier to follow what is happening in this file!
I'm glad you find them helpful! I thought being overly verbose for this file in particular might be helpful in preventing future regressions.
packages/platform/src/editor/ScriptureReferencePlugin.test.tsx line 236 at r1 (raw file):
Previously, katherinejensen00 wrote…
NIT Documentation Cleanup
Summary: This comment explains the setup in terms of
hasCursorMovedRef, a ref this PR deletes.
hasCursorMovedRefexisted only in the old implementation. The line "Clear hasCursorMovedRef (set by initial 'move cursor to verse start' effect) so our dispatch runs BCV logic" describes a mechanism that no longer exists — and the characterization test 30 lines below (line 263) already states the correct new-code reason: "a no-op report (position == scrRef) in the new code." Update line 236 to match, so the two comments stop contradicting each other.
Good point - fixed this comment.
packages/platform/src/editor/ScriptureReferencePlugin.test.tsx line 527 at r1 (raw file):
Previously, katherinejensen00 wrote…
NIT
Summary:
expect(startNode?.getTextContent()).not.toBe("second verse text ")is nearly always true and pins little.In "applies a navigation to verse 0," the caret started on
secondVerseTextNodeand the assertion only checks it left that text — almost any landing (including a wrong one) passes. Prefer asserting the caret is in the chapter-top region you actually expect. Note: the visually-identical assertion at line 577 (placement-gate test) is fine as-is — the swapped-in EXO fixture also contains"second verse text ", so there it precisely targets the known wrong-placement node, as the line-576 comment explains. Only line 527 is weak.
Good point. Reworked the assertion.
- moved helper functions below the main plugin export function following the existing code ordering pattern. - minor doc changes to improve clarity. - renamed a couple of helper functions for clarity. - changed to a named plugin export (using a `default` export is the old style). - use `editor.read()` instead of `editor.getEditorState().read()` - it flushes pending updates before reading and provides editor context, and is the recommended form.
* cleanup after PR #499 - moved helper functions below the main plugin export function following the existing code ordering pattern. - minor doc changes to improve clarity. - renamed a couple of helper functions for clarity. - changed to a named plugin export (using a `default` export is the old style). - use `editor.read()` instead of `editor.getEditorState().read()` - it flushes pending updates before reading and provides editor context, and is the recommended form. * Revert editor.read() back to editor.getEditorState().read() Code review demonstrated the two are not synonyms: editor.read() runs $commitPendingUpdates first - a full synchronous commit with no _updating guard. At the onPropChanged site that flush installs a pending document swap and then approves it, inverting the I6 stale-book gate, splits one navigation into two commits, and triggers React's "flushSync was called from inside a lifecycle method" warning via useDecorators. At the mutation-listener site the flush is provably value-neutral on the normal path but re-enters the listener from the skipInitialization replay. Both sites now go through a getCommittedBookCode helper that documents the committed-snapshot requirement, and the header's UNSAFE list gains an entry for editor.read() since no test pins the hazard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review cleanup comments on ScriptureReferencePlugin - rename onDocumentChanged's bookCode param and the listener local to match getCommittedBookCode (finishes the contentBook -> bookCode rename) - warn at the SELECTION_CHANGE_COMMAND call site that $resolvePosition must stay inline, now that onSelectionSettled's doc lives far below - move MAX_PENDING_ECHOES next to report(), its only consumer, and give it a doc comment - label onDocumentChanged's branches (a)-(d) so the existing cross-references resolve - capture isFirstDocument before the single sawDocument write so the read-before-write ordering is explicit instead of emergent - replace the hand-rolled sibling walk in $getFirstBookNode with the getChildren().find() idiom used elsewhere - document $moveCaretToVerseStart's already-in-range no-op - alphabetize imports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
Navigation through the editor has been intermittently unreliable: verses reset to 0 (e.g. Psalms 78:9 -> 78:0 on scroll-group reattach, or on any view-option toggle), navigations land somewhere other than their target during rapid check-list movement, and scripture references get stuck or out of sync. #498 fixed one trigger, but a deep review found the causes were structural, spread across the whole plugin:
hasCursorMovedRef/hasSelectionChangedRef) whose protocols could be wedged, stolen, or double-consumed by event timing; andbookprop paired with the stale document's chapter/verse, and vice versa), producing references that describe no real position.Every symptom is one of six races among three async signals (the scrRef prop, document content arriving later than the intent it belongs to, and selection events trailing everything) plus the echo loop (our own reports returning as the prop).
Fix
The plugin is rebuilt around an explicit, documented state machine (see the file header, which now carries the full contract, race table R1-R6, invariants I1-I8 each backed by a named test, deliberate trade-offs, and safe/unsafe change lists):
phase: "idle" | "navigating"- while an external scrRef change (or a document replacement / book correction) is being applied, the plugin emits nothing. The window closes on real user input (pointerdown,keydown,beforeinput) or when one of the plugin's own reports echoes back as the prop. Settles are silenced by state, not by fingerprinting selection shapes - so verse 0 stays an ordinary, addressable reference (P9 semantics), never a sentinel.pendingEchoesFIFO - emissions not yet seen back as the prop. A prop change matching one is our own echo: consumed through the match (React batching means only the last of several in-flight reports returns) and never moves the caret. A queue rather than a boolean/slot: two reports can be in flight before the first echo returns.report()) with exactly two shapes: position reports (book + chapter + verse from a single document snapshot) and the book-correction contract ({...scrRef, book}) for documents whose book differs from the ref.Review hardening included: malformed verse ranges ("3-2") no longer throw inside the selection listener; empty chapter numbers resolve as unaddressable instead of emitting NaN; position resolution is gated on phase.
Testing
Notes for review
AI-assisted (Claude Code)
🤖 Generated with Claude Code
https://claude.ai/code/session_01YGsAqgwChpCeCc8KW4o3HF
This change is