Skip to content

Clean up ScriptureReferencePlugin after PR #499#500

Merged
irahopkinson merged 3 commits into
mainfrom
cleanup
Jul 16, 2026
Merged

Clean up ScriptureReferencePlugin after PR #499#500
irahopkinson merged 3 commits into
mainfrom
cleanup

Conversation

@irahopkinson

@irahopkinson irahopkinson commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Code Review Summary

Branch: cleanup

Base: origin/main

Date: 2026-07-14

Review model: Claude Sonnet 5

Files changed: 3

Overview

This branch is a cleanup pass following PR #499 ("rebuild ScriptureReferencePlugin as a navigation state machine"), touching only packages/platform/src/editor/Editor.tsx, ScriptureReferencePlugin.tsx, and ScriptureReferencePlugin.test.tsx. It reorganizes helper functions to sit below the main plugin export (matching the convention used elsewhere in the package), renames a couple of helpers for clarity ($moveCursorToVerseStart$moveCaretToVerseStart, schedulePlacementschedulePlacingCaretAtVerseStart), switches the plugin from a default export to a named export, and replaces editor.getEditorState().read() with editor.read() at the two sites where that's the correct pattern. It also restores a chunk of pre-PR-#499 logic in $moveCaretToVerseStart, described in the commit message as restoring "affordance for more than one chapter in USJ."

That last item turned out to be the one substantive discussion point: three independent analysis passes, followed by a detailed manual trace (including reading $findNextChapter's actual implementation and diffing all three historical versions of the function — pre-#499, PR #499, and this cleanup), confirmed the "restored" guard was behaviorally identical to the PR #499 code it replaced — it didn't deliver the stated affordance, just reordered computation. The author agreed and had this hunk reverted to the PR #499 form during the review (see Findings). A genuine but unrelated pre-existing edge-case bug in $findNextChapter was surfaced along the way (see Suggested Review Focus) but is not part of this diff and was intentionally left alone.

API Changes

None. ScriptureReferencePlugin.tsx is an internal editor-implementation file, not re-exported through packages/platform/src/index.ts, and is not tracked by the package's api-extractor report (packages/platform/etc/platform-editor.api.md), which has no diff versus the merge base. Editor.tsx only changed one import line to match the new named export. No changes touch libs/shared/, libs/shared-react/, packages/utilities/, or any package's public surface.

Findings

Critical — Must address before merge

None.

Important — Should address before merge

Author confirmed the guard restoration was "probably more restore to match old code" than an intentional behavior fix, and asked a substantive follow-up question about $findNextChapter's second argument before agreeing to revert it — see Interview Notes.

Minor — Consider

None.

Template Propagation

Shared Regions Modified

None. No #region shared with <url> markers in any changed file.

Extension Config Changes

Not applicable — this repository has no extensions/-directory/template-propagation convention.

Positive Observations

  • The reorg (helpers moved below the exported component) matches the established convention elsewhere in the package (ArrowNavigationPlugin.tsx, NoteNodePlugin.tsx, ParaMarkerPrefixGuardPlugin.tsx, ActiveTextPlugin.tsx, TreeViewPlugin.tsx).
  • default export → named export function ScriptureReferencePlugin matches how other plugins in this codebase are exported/consumed; confirmed no other file imports the old default export.
  • editor.read(...) replacing editor.getEditorState().read(...) was applied at exactly the two correct sites, and the one site that must not use this pattern ($resolvePosition, inline in the SELECTION_CHANGE_COMMAND handler) was correctly left alone, with its explanatory comment about why intact.
  • Renames (schedulePlacementschedulePlacingCaretAtVerseStart, $moveCursorToVerseStart$moveCaretToVerseStart, "window" → "navigation window" in prose) were applied consistently everywhere except the two stale spots noted above (now fixed).
  • All 37 existing tests in ScriptureReferencePlugin.test.tsx pass unchanged; nx lint/nx typecheck clean for the package — confirmed both before and after the in-review fixes.

Interview Notes

Author's stated purpose (from the branch's commit message): "cleanup after PR #499" — reorg, doc clarity, renames, named export, editor.read() adoption, and restoring the multi-chapter guard.

The one substantive discussion was around the restored guard in $moveCaretToVerseStart. When asked whether it fixed a real observed bug or was a "restore to match old code" move, the author said it was probably the latter. When told the guard reorder appeared to be behaviorally inert, the author pushed back with a sharp, specific technical question — whether moving the return check to after computing nextChapterNode could matter given what $findNextChapter's second argument does. That was a good challenge and led to tracing the actual implementations of removeNodesBeforeNode and $findNextChapter, and diffing all three historical versions of the function (pre-#499 commit 6bb03956, PR #499 commit b315cac9, and this cleanup). That trace confirmed no behavioral difference in either branch of the guard, but surfaced a real, pre-existing edge case: when the current chapter has zero verses (immediately followed by another chapter node), $findNextChapter's hardcoded isCurrentChapterAtFirstNode=true skips index 0 of the sliced node array and misses that next chapter node, causing verse lookup to leak into the following chapter's content. This bug exists identically in both PR #499 and pre-#499 code — it predates this diff and is unrelated to it, so it was intentionally not fixed here (see Suggested Review Focus for a follow-up).

Based on this, the author agreed to revert the guard hunk to the PR #499 form, and separately asked for the two stale $moveCursorToVerseStart references to be fixed. Both were done during the review (see Findings).

No unresolved items — the author engaged directly and substantively with the one non-trivial technical question raised, rather than deferring or expressing uncertainty. No areas where the author did not understand their own changes.

Separately: the branch's commit was amended and pushed to origin/cleanup mid-review by the author directly (not by any action taken during this review) — confirmed by the author and by the subagent that ran quality checks, which reported it took no git commit/git commit --amend/git push actions of its own.

In-Review Quality Check

After the two in-review fixes (guard revert, stale-name fixes), a subagent ran the package's checks against @eten-tech-foundation/platform-editor (the Nx project for packages/platform):

  • Typecheck (nx typecheck) — passed, no errors.
  • Lint (nx lint) — passed clean, no auto-fix needed.
  • Format (prettier --write on the two edited files) — both already correctly formatted, no changes.
  • Tests (nx test) — 148 passed, 3 skipped (unrelated data-generation tests), 0 failed. All 37 tests in ScriptureReferencePlugin.test.tsx passed.

No unfixable failures. No issues found that need the author's attention.

Suggested Review Focus

AI-assisted — session


This change is Reviewable

- 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.
@codesandbox

codesandbox Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@katherinejensen00 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for making these fixes, Ira! The code looks good so I will go ahead and approve. There are two documentation notes that maybe you can fold into another PR later, but no worries if you don't.

@katherinejensen00 reviewed 3 files and all commit messages, and made 3 comments.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on irahopkinson).


packages/platform/src/editor/ScriptureReferencePlugin.tsx line 340 at r1 (raw file):

    // synthetic selection settle fires before the deferred placement and must be silenced -
    // regardless of book match, since a same-book reload's transient chapter-top settle is the
    // R2 clobber this branch exists to prevent. The correction below (d), if any, runs after.

NIT Claude Finding

The (a)(d) cross-references in onDocumentChanged are used in prose but never anchored to their code branches.

The REPLACEMENT branch says "The correction below (d), if any, runs after." The book correction block says "Runs after (a)–(c); under (a) phase is already 'navigating' here." But no comment in the code is actually labeled // (a), // (b), etc. — a reader has to manually count branches to decode the letter scheme. If (a) is REPLACEMENT, (b) is MOUNT, (c) is the paste/undo no-op, and (d) is the book correction block, that can be inferred, but it takes real effort. This predates your PR, so it doesn't need to block merge — but a one-liner label at the start of each branch (// (a) REPLACEMENT: ..., etc.) would make the cross-references self-evident.


packages/platform/src/editor/ScriptureReferencePlugin.tsx line 375 at r1 (raw file):

}

function $moveCaretToVerseStart(chapterNum: number, verseNum: number) {

NIT Claude Finding
$moveCaretToVerseStart is the only named handler in the file without a JSDoc, and it's the most complex one.

Every other handler in the file — onPropChanged, consumePendingEcho, $resolvePosition, onDocumentChanged, schedulePlacingCaretAtVerseStart, onSelectionSettled, positionMatchesScrRef, verseInRangeSafe, positionToScrRef, report, refsEqual, onUserInteracted — has a function-level JSDoc comment. $moveCaretToVerseStart doesn't, and it's the function with the most state assumptions: it must be called inside a Lexical editor-state update, it has an early-return when the caret is already in a verse range, and it handles the "no verse node, fall back to para" case. The file-top HOW-THIS-PLUGIN-WORKS block explains the overall algorithm but doesn't substitute for a function signature–adjacent description that callers read in context. Since this PR renamed the function and moved it, this is a good moment to close that gap with even two or three sentences.

@lyonsil lyonsil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: the reorg is clean, but two editor.read() swaps are not cleanup

I verified the mechanical half of this PR and it is solid: every moved function body is byte-identical apart from the two swaps below, no other file imports the old default export, no barrel/mock/lazy-import references it, and nx extract-api is not needed (the plugin is not on the package's public surface). No CLAUDE.md violations, no over-length lines, no em dashes.

The concern is the two editor.getEditorState().read(cb) -> editor.read(cb) swaps (lines 199 and 270). In Lexical 0.43 those are not synonyms (Lexical.dev.mjs:10935):

read(callbackFn) {
  $commitPendingUpdates(this);   // <-- synchronously FLUSHES pending updates
  return this.getEditorState().read(callbackFn, { editor: this });
}

There is no _updating guard - the very guard Lexical applies to itself in setEditorState ("otherwise this will cause a second commit with an already read-only state and selection") and warns about in triggerCommandListeners ("...will potentially corrupt the nodeMap by doing GC too early").

And the flush window is real in production: LoadStatePlugin calls editor.setEditorState() inside editor.update(), so _updating === true, setEditorState's own commit is skipped, and _pendingEditorState survives - holding the new document - until a later microtask.

Recommendation: revert both swaps to editor.getEditorState().read(...). At line 199 the flush is provably incapable of changing the value read (Lexical nulls _pendingEditorState at :8504, before firing mutation listeners at :8605), so it is pure risk for zero benefit. At line 270 it is actively wrong: it commits a pending document and then reads it, which is exactly what the gate on the next line exists to prevent. The other two mutation listeners in this editor (AnnotationPlugin.tsx:85, CommentPlugin.tsx:862) both still use getEditorState().read(...), so reverting restores consistency rather than breaking it.

If the terseness is the goal, extract a named helper instead - see the comment on line 199.

Comments below: 1-6 are introduced by this PR; 14-25 are cleanup notes held to the PR's own "cleanup" billing. (I also found 7 pre-existing defects from #499 in the functions this PR moved - empty-book-code placement, sawDocument vs book-less documents, a stale verse range riding into book corrections, and others. Those are not this PR's doing and I have left them off this review; happy to file them separately.)

Verified against Lexical 0.43.0 and React 18.3.1 (note: repo CLAUDE.md still says React 19.1.0 - it resolves 18.3.1).

machine.phase = "navigating";
machine.pendingEchoes.length = 0;
// Prop-driven placement gate: never move the caret inside a different book's (stale) document.
const bookCode = editor.read(() => $getFirstBookNode()?.getCode() || undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[1/18 - CONFIRMED] The flush makes useDecorators call flushSync inside React's commit phase.

onPropChanged runs from a React passive effect. When this editor.read() flush is non-inert, $commitPendingUpdates fires Lexical's decorator listener, and @lexical/react's useDecorators (LexicalRichTextPlugin.dev.mjs:72-77) calls flushSync. That now runs inside flushPassiveEffects, so React logs "flushSync was called from inside a lifecycle method" and silently defers the decorator render instead of flushing it.

Reproduced end-to-end against real React 18.3.1 + Lexical 0.43.0 with a LexicalComposer + RichTextPlugin + a DecoratorNode. This is not a corner case: ImmutableVerseNode, ImmutableChapterNode, ImmutableNoteCallerNode, MilestoneNode all extend DecoratorNode, so any document-load FULL_RECONCILE populates _pendingDecorators.

Worth noting LoadStatePlugin already guards this exact hazard on purpose: "Use queueMicrotask to defer the editor update outside of React's lifecycle, preventing flushSync warnings when this is triggered by a parent component update." This line reintroduces precisely what that queueMicrotask exists to avoid.

This is the one consequence of the swap that is live and confirmed rather than latent.

Fix: editor.getEditorState().read(...).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 36bceac - both sites reverted to editor.getEditorState().read() via a getCommittedBookCode helper that documents the committed-snapshot requirement.

machine.phase = "navigating";
machine.pendingEchoes.length = 0;
// Prop-driven placement gate: never move the caret inside a different book's (stale) document.
const bookCode = editor.read(() => $getFirstBookNode()?.getCode() || undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[2/18 - PLAUSIBLE, most severe] editor.read() has no _updating guard, so it can commit a half-built editor state.

report() calls the host's onScrRefChange from inside onSelectionSettled, which Lexical runs inside an open $beginUpdate (_updating === true, _pendingEditorState a live mutable clone) - the file's own comment on onSelectionSettled says as much. If the host responds with a synchronous re-render (flushSync, a sync external store), React flushes the pending passive effect re-entrantly, onPropChanged runs, and this editor.read() commits that half-built state: $commitPendingUpdates sets _readOnly = true and freezes the selection / swaps the node map for throwing stubs while $beginUpdate is still between updateFn() and $applyAllTransforms/$garbageCollectDetachedNodes.

Reproduced under a flushSync host:

MODE=pr   (editor.read)           -> errors: ['Cannot call set() on a frozen Lexical node map']
                                     committed text: "GEN"   <-- enclosing update's work LOST
MODE=main (getEditorState().read) -> errors: []
                                     committed text: "GEN\n\nWORK-AFTER-THE-READ"  <-- survives

The enclosing update's tail-scheduled commit then no-ops, because _pendingEditorState is already null - so the edit is silently dropped. And the freeze guard is dev-only (grep -c "frozen Lexical node map" Lexical.prod.mjs -> 0), so production corrupts silently where dev throws.

This is exactly the hazard Lexical guards in setEditorState with if (!this._updating) { $commitPendingUpdates(this); }. editor.read() has no such guard.

Marked PLAUSIBLE only because the trigger depends on whether the Platform.Bible host commits scrRef synchronously - which I cannot see from this repo. Could you confirm whether the host wraps the scrRef update in flushSync or a sync store? If it does, this is a crash-and-lose-the-edit bug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Update: I answered my own question - the trigger is NOT present. Downgrading this to latent.

Rather than leave this hanging for you, I traced the host chain in paranext-core:

platform-scripture-editor.web-view.tsx:1692 passes onScrRefChange={setScrRefNoScroll} -> setScrRefNoScroll (:1127-1140) -> setScrRefWithScroll from useWebViewScrollGroupScrRef() -> useScrollGroupScrRef (use-scroll-group-scr-ref.hook.ts:99-114) -> setScrRefLocalIfDifferent -> setScrRefLocal, a plain useState setter (:67).

flushSync, useSyncExternalStore and unstable_batchedUpdates appear zero times in the host's first-party source. The only flushSync in the dependency tree is Lexical's own useDecorators, which fires at the end of a commit rather than re-entrantly inside onScrRefChange. (setScrRefSync in scroll-group.service-host.ts is a red herring - "Sync" there means "not Promise-returning", nothing to do with React.) The feedback loop back into React is useEvent, a plain useEffect subscription, not useSyncExternalStore. The other three consumers (resource-text-panel, model-text-panel, enhanced-resource) go through the same hook.

This finding requires a re-entrant passive-effect flush inside an open Lexical update (_updating === true), and nothing in the current host produces one. So it is latent, not live - please don't spend time on it. It remains an argument for not using editor.read() here (the API gives you no guard if a host ever introduces a synchronous commit), but it is not a bug you can hit today.

This does not affect findings 1 and 3 on this line. Those need only _pendingEditorState !== null with _updating === false - i.e. after the stack has unwound - which the plain-setState host does produce: onScrRefChange fires from a selectionchange-driven Lexical command listener, React treats that as discrete/sync-lane and schedules its flush as a microtask, and that microtask can run before Lexical's own deferred commit microtask. Both still stand.

Sorry for putting an open question in front of you that was mine to answer.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved by the revert in 36bceac - editor.read() is gone from the file, and the header's UNSAFE list now bans it. Agreed it's latent given the host's plain useState setter chain; the revert removes the exposure either way.

machine.phase = "navigating";
machine.pendingEchoes.length = 0;
// Prop-driven placement gate: never move the caret inside a different book's (stale) document.
const bookCode = editor.read(() => $getFirstBookNode()?.getCode() || undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[3/18 - PLAUSIBLE] Gate inversion: the flush commits the pending document, then reads it - defeating invariant I6.

The comment right above this line states the purpose: "never move the caret inside a different book's (stale) document", and I6 says "prop-driven placement never moves the caret in a different book's document". That question can only be answered by the currently loaded document - which is what getEditorState().read() returned.

editor.read() commits the pending swap first, then reads it. Demonstrated against real Lexical 0.43.0, replicating LoadStatePlugin's queueMicrotask(() => editor.update(() => editor.setEditorState(...))):

_pendingEditorState !== null : true   _updating: false
OLD  editor.getEditorState().read() -> "GEN"
NEW  editor.read()                  -> "EXO"
GATE INPUT CHANGED BY THE PR        : true

So the gate on line 271 passes where it previously (correctly) failed, and a prop-driven placement runs inside a document the user cannot see yet. The gate also stops being a pure predicate: evaluating it installs the document it then approves.

The read is also demonstrably reachable with a live pending state and a plain host (no flushSync): Lexical dispatches SELECTION_CHANGE_COMMAND synchronously inside the DOM selectionchange event, which React 18 treats as DiscreteEventPriority -> SyncLane, and a SyncLane commit flushes its passive effects synchronously (react-dom.development.js:26973). I measured 3 hits of this line with _pendingEditorState !== null. What I did not reproduce end-to-end is that window coinciding with a cross-book swap - hence PLAUSIBLE rather than CONFIRMED.

No test pins this: all 37 stay green either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 36bceac - the gate reads the committed snapshot again (getCommittedBookCode), so evaluating it can no longer install the document it then approves.

// Prop-driven placement gate: never move the caret inside a different book's (stale) document.
const bookCode = editor.read(() => $getFirstBookNode()?.getCode() || undefined);
if (!bookCode || bookCode === newRef.book) {
editor.update(() => $moveCaretToVerseStart(newRef.chapterNum, newRef.verseNum), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[5/18 - CONFIRMED] Side effect of the line-270 flush: it splits one commit into two.

Without the flush, this editor.update() merges into the still-pending document state ($beginUpdate sees pending non-null and not _readOnly, so editorStateWasCloned = false) and the document and the caret move commit together.

With the flush, the document commits first - firing every update/mutation/decorator listener with the caret not yet placed - and the caret move then commits separately. So DeltaOnChangePlugin, HistoryPlugin and the annotation plugins all observe an intermediate state that previously never existed.

This resolves itself if line 270 goes back to getEditorState().read(...).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 36bceac - with the flush gone, the caret move merges into the still-pending document state again, so the document and caret commit together.

const code = editor
.getEditorState()
.read(() => $getFirstBookNode()?.getCode() || undefined);
const code = editor.read(() => $getFirstBookNode()?.getCode() || undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[4/18 - CONFIRMED] At this site the swap provably buys nothing, and adds a re-entrancy.

On the normal path this listener runs from inside $commitPendingUpdates, which nulls _pendingEditorState at Lexical.dev.mjs:8504 and installs the new state at :8505 - 100 lines before it fires mutation listeners at :8605. So editor.read()'s $commitPendingUpdates hits the early return at :8486-8488, and getEditorState() already holds exactly the state it would have returned. The value read cannot differ. Zero benefit.

The risk is not zero, though. This listener explicitly opts into { skipInitialization: false }, and Lexical's registerMutationListener inserts the listener into _listeners.mutation (:10648) before calling initializeMutationListener (:10653), which invokes it synchronously from inside the useEffect - not inside a commit, so _pendingEditorState is not nulled there. If a pending update exists at that moment, this editor.read() runs a full commit whose triggerMutationListeners iterates Array.from(editor._listeners.mutation) (:8646) - which already contains this listener - and re-enters it. onDocumentChanged then runs twice for one document arrival, mutating machine.phase / sawDocument / pendingEchoes under itself.

The pre-PR getEditorState().read() could not do this. Note the file's own header calls the skipInitialization replay out as the source of the NUM 27:1 bug.

Fix: revert to editor.getEditorState().read(...) - which is also what AnnotationPlugin.tsx:85 and CommentPlugin.tsx:862 still use.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 36bceac - reverted via the shared getCommittedBookCode helper, which documents why editor.read() is off-limits here (including the skipInitialization replay re-entry).

* @returns null, i.e. no DOM elements.
*/
export default function ScriptureReferencePlugin({
export function ScriptureReferencePlugin({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[21/18 - cleanup] The export default -> named export migration is half-applied, and it creates a name collision.

Half-applied: TreeViewPlugin is still export default and is imported on the line directly below this one's import in Editor.tsx (import TreeViewPlugin from "./TreeViewPlugin";, line 9). CommentPlugin and Editor itself are also still default exports. A convention applied to one of four plugins in a directory is a diff, not a convention - either finish it or leave it.

Collision: libs/shared-react/src/plugins/ScriptureReferencePlugin.tsx exports a different plugin under the same name (props {book?, onChangeReference?}, both optional), re-exported from the shared-react barrel that Editor.tsx already imports ~25 names from. Two identically-named exports now sit in Editor.tsx's import scope; IDE auto-import will offer both, and because both of the other plugin's props are optional, <ScriptureReferencePlugin /> from the wrong module type-checks silently and yields a no-op plugin. The default export was the only thing preventing this.

Not a blocker - just worth knowing the migration bought a footgun that didn't exist before.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving as is for this PR. Scribe's ScriptureReferencePlugin is already a named export under the same name, so named exports are the established direction here. Finishing the migration (TreeViewPlugin, CommentPlugin, Editor) and dealing with the legacy shared-react plugin (whose only consumer is the perf-react demo) belongs in a follow-up.


/** isVerseInRange throws on malformed ranges (reversed, 3-part) from imported USFM; a malformed
* range simply doesn't contain the verse. */
function verseInRangeSafe(verseNum: number, verseRange: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[22/18 - cleanup/reuse] One root cause, guarded twice in this file.

isVerseInRange (libs/shared/src/nodes/usj/node.utils.ts) throws on malformed ranges. This file catches that single throw in two different ways: this verseInRangeSafe wrapper (used at lines 378 and 427), and a separate ad-hoc try/catch around $findVerseOrPara at lines 391-396 - which only throws because $findVerseInNode (libs/shared-react/src/nodes/usj/node-react.utils.ts) calls the same unguarded isVerseInRange. The comment above that try/catch admits as much.

So the guard is at the wrong altitude: two mechanisms here for one root cause, and $findVerseOrPara / $findVerseInNode stay throw-prone for every other consumer of the shared-react barrel.

Better: export an isVerseInRangeSafe sibling next to isVerseInRange in shared, have $findVerseInNode use it, then delete both this wrapper and the try/catch at 391-396. (Don't change isVerseInRange itself - its throw is pinned by node-utils.test.ts.)

Out of scope for a cleanup PR if you'd rather not - but this PR is the cleanup PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the direction, but moving the guard into shared/shared-react changes malformed-range behavior for scribe and the demos too - that deserves its own PR and tests rather than riding on this one. Leaving both local guards for now.

}

/** First BookNode of the root (documents put it first; strays after content are ignored). */
function $getFirstBookNode(): BookNode | undefined {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[23/18 - cleanup/reuse] This hand-rolled sibling walk is $getRoot().getChildren().find($isBookNode).

Six lines of getFirstChild() / getNextSibling() for what the codebase already expresses as a one-line find with a type guard - which is the established idiom here: $getFirstPara (node-react.utils.ts), $findChapter and $findNextChapter (node.utils.ts) are all written that way, and this plugin's own test file already writes exactly $getRoot().getChildren().find($isBookNode) (ScriptureReferencePlugin.test.tsx:471).

$moveCaretToVerseStart in this same file already calls $getRoot().getChildren() (line 382), so the file now carries two different root-scan shapes for no behavioral gain.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.


// The unaddressable check needs the NODE's existence - a BookNode with an empty code still
// makes a document addressable.
const bookNode = $getFirstBookNode();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[24/18 - cleanup/efficiency] $resolvePosition re-walks the root children on every selection change.

This runs on every idle selection change: every caret move, every typed character, every click, continuously during a mouse drag, plus the synthetic dispatch this plugin's own verse listener fires on every verse created/destroyed batch. Each call re-derives the first BookNode by walking root children.

In a well-formed document the BookNode is the first child, so it's cheap. But for the book-less document this file explicitly supports (the I1 prop-book fallback), the loop visits every root child before returning undefined - one per para, ~40 for a typical chapter, 190+ for a Psalm-119-shaped one - on every caret move.

The value is invariant between BookNode mutations, and registerMutationListener(BookNode, ...) on line 196 is already the perfect invalidation hook - it computes exactly this value and throws it away. Caching it on Machine would delete the walk from the hot path entirely. (Would need invalidating on the destroy-only early return at line 198 too.)

Low priority - flagging it because the mechanism to fix it already exists 100 lines up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Declining, with context the repo doesn't show: the host splits USJ into chapters and loads one at a time, and only chapter 1's document carries a BookNode - so the full-walk miss is actually the common case here, not the book-less edge case. It stays bounded by paras-per-chapter (~40, ~190 for Psalm 119), i.e. microseconds per selection event. And the proposed Machine cache can't invalidate on chapter N -> N+1 swaps, which create/destroy no BookNode and fire no mutation at all - a stale-state hazard in exactly the file that exists to contain them.

});
}

function $moveCaretToVerseStart(chapterNum: number, verseNum: number) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[25/18 - cleanup] The only helper in the file with no doc comment - and its first branch contradicts its name.

Every other helper here carries a /** */ (report, consumePendingEcho, $resolvePosition, positionMatchesScrRef, verseInRangeSafe, ...). This one has none, and lines 376-380 deliberately do not move the caret when it already sits inside a verse range containing verseNum (pinned by "should not move the cursor if already in range") - i.e. the function's first act is the opposite of what $moveCaretToVerseStart promises, with no explanation.

The other three no-op exits (no chapter, $findVerseOrPara throws, no target found) are unsurprising, so the name is fine - the gap is just the one-liner. Since this PR renamed this exact signature line, it seems like the moment to add it:

/** Moves the caret to the start of `verseNum` in `chapterNum`. No-op when the caret is already
 * inside a verse range containing `verseNum` (a range is one location), or the target is absent. */

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in c8459c7, essentially verbatim.

@lyonsil

lyonsil commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The previous comments were directly posted after a /code-review max pass. Regarding the "pre-existing bugs" note, most of them rely on the following:

  • Books with missing IDs
  • Documents with no book node
  • Other plug-ins working differently than they do today

I'm double checking there aren't new, meaningful issues it says were latent. So far, I haven't found any.

It seems like Claude is most unhappy with the changes to remove getEditorState(). I didn't dig into each item in depth, but I did scan them to make sure they seemed plausible enough to consider.

@lyonsil

lyonsil commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Four pre-existing latent issues in ScriptureReferencePlugin (not introduced by this PR - none should block it)

While reviewing this PR I traced four defects that are not this PR's doing. They come from #499 and earlier; #500 only moved the code they live in, which is how they surfaced. I'm putting them here rather than inline so they don't clutter the diff.

None of them causes a problem today. Each needs a specific condition that no current code path or consumer produces (with one caveat on L1 below, which I'll be honest about). All four are reproduced against the unmodified plugin. Entirely your call whether to fold any in now or leave them for later - I'd merge this PR on its own merits regardless.


L1. A BookNode with an empty code never gets the caret placed on navigation

BookNode.getCode() is typed BookCode | "" (libs/shared/src/nodes/usj/BookNode.ts:90), and the adaptor deliberately tolerates a bad code - usj-editor.adaptor.ts:226-229 warns and then emits it anyway as code ?? ("" as BookCode). Line 199 collapses that to undefined:

const code = editor.read(() => $getFirstBookNode()?.getCode() || undefined);   // "" -> undefined

and the arrival gate at line 329 requires a truthy code:

if (batch.hasCreated && code && code === machine.scrRef.book) {
  schedulePlacingCaretAtVerseStart(machine, editor);
}

so the placement never runs. Reproduced: control document coded GEN, navigating to GEN 2:9, caret lands on "verse nine ". Identical document coded "", caret lands on "Test Book" - the top of the document.

Because Platform.Bible documents are single-chapter, arrival-driven placement is the path every navigation takes, so in a project with a codeless \id, every navigation would leave the caret at the document top.

There's also a three-way inconsistency worth knowing about: the gate at line 271 (!bookCode || ...) treats an empty code as "no book, so place anyway", line 329 treats it as "not my document", and $resolvePosition (lines 296-300) takes a third position, keying addressability on the node's existence with a comment explaining why: "a BookNode with an empty code still makes a document addressable."

Trigger: a USJ \id with a missing or empty code. Caveat: this is the one whose trigger is data-dependent rather than code-dependent, so if such projects exist in the wild it is already live rather than latent. I couldn't determine whether they do - you'd know better than I would.


L2. sawDocument is never set for a book-less document, so a later paste teleports the caret

machine.sawDocument is written only inside onDocumentChanged, which only ever runs from the BookNode mutation listener. Lexical's initializeMutationListener does not invoke a listener when no node of that class exists, so a document with zero BookNodes never fires it - sawDocument stays false even though a document is loaded.

A later cross-editor paste carrying a BookNode then arrives as {hasCreated: true, hasDestroyed: false} with phase idle, and line 343 matches:

} else if (batch.hasCreated && !machine.sawDocument) {
  // MOUNT: fresh editor, no navigation window needed
  schedulePlacingCaretAtVerseStart(machine, editor);
}

The plugin thinks it is mounting a fresh document and teleports the caret. Reproduced: caret jumped from "bookless verse one " to "bookless verse three ". Note beforeinput does not save you here - the guard on this path is sawDocument, not phase.

That contradicts the trade-off the file header calls pinned (lines 67-69): "A cross-editor paste or undo that (re)creates a BookNode while a document is already loaded triggers neither placement nor a navigation window: pastes must not teleport the caret to verse start (pinned)." The pinning test only covers the has-a-BookNode case.

Trigger: a USJ with no book element. Book-less documents are an explicitly designed input of this file (ResolvedPosition.book is "undefined when it has none"; the I1 prop-book fallback exists for exactly this), but I found no path in paranext-core that sends one and every fixture in the repo has a book. If you can confirm the host always sends \id, this is dead code and you can dismiss it.


L3. A late mount over an already-loaded document emits a spurious verse-0 ref

The plugin is conditionally mounted (Editor.tsx:422) and both props are optional (editor.model.ts:169-171):

{scrRef && onScrRefChange && <ScriptureReferencePlugin scrRef={scrRef} onScrRefChange={onScrRefChange} />}

So a host that writes scrRef={isReady ? scrRef : undefined} - a natural thing to write against optional props - mounts the plugin over an already-loaded document. Then, in a single synchronous effects flush:

  1. The BookNode listener's { skipInitialization: false } replay fires -> onDocumentChanged takes the MOUNT branch (line 343), which queues placement as a microtask and deliberately does not set phase. Same book, so no correction is emitted either. Phase stays "idle".
  2. The two verse listeners (lines 237-238) are registered with no options - and Lexical's DEFAULT_SKIP_INITIALIZATION is false (Lexical.dev.mjs:10128), so they replay too. onVerseDestroyed counts "created" as a trigger and fires editor.dispatchCommand(SELECTION_CHANGE_COMMAND) synchronously.
  3. The command listener sees phase === "idle" -> onSelectionSettled -> the caret is at the chapter top -> resolves to verse 0 -> report() emits.
  4. Only then does the placement microtask run.

Steps 1-4 are one synchronous call stack, so queueMicrotask provably cannot run until after the emission. Reproduced against the unmodified plugin, with the assertion read on the line right after mount with no await in between (so no microtask can have run):

emitted: { book: "GEN", chapterNum: 1, verseNum: 0 }

That's the R2 verse-0 clobber, at mount, before placement.

The registration-order guard only half-saves this. It works for the cross-book late mount (the mount correction sets phase = "navigating" before the verse replay dispatches). But in the same-book case - the common one - no correction is emitted and the MOUNT branch never flips the phase, so ordering buys nothing. The header's note about "a document swap must flip phase to navigating here before the verse listener's synchronous dispatch" is about the REPLACEMENT branch; the MOUNT branch has no such flip.

Why it's latent: Editor.tsx:172 passes editorState: undefined, so the composer is empty at plugin mount and both replays are no-ops (measured: 0 dispatches, 0 emissions). The document then arrives via LoadStatePlugin, whose new state carries a null selection, so the verse dispatch resolves to nothing and the placement microtask wins. And no current consumer late-mounts - paranext-core always passes scrRef from useWebViewScrollGroupScrRef(), which is never undefined.

Trigger: any host that gates the props on a loading state. Also: Vite HMR already does this in dev - editing the plugin module remounts it over a loaded document. If anyone has ever seen a phantom verse-0 after a hot reload, this is why.

The root cause is an implicit/explicit asymmetry: the BookNode listener opts into the replay explicitly ({ skipInitialization: false }), while the verse listeners 35 lines below get the same replay implicitly from Lexical's default - which reads as though they don't replay. The file's own UNSAFE list headlines that replay path as the cause of the NUM 27:1 bug.

Cheapest fix: have onVerseDestroyed ignore the registration-time replay (skip when the update tags indicate registerMutationListener, or pass { skipInitialization: true } if the replay isn't wanted there), which kills the dispatch at source without touching any of the six pinned races. Alternatively, have the MOUNT branch set phase = "navigating" when a non-matching selection already exists.


L4. REPLACEMENT detection is load-bearing on destroy+create arriving in ONE mutation batch

Line 198 discards a destroy-only batch outright, recording nothing that a destroy happened:

if (kinds.every((kind) => kind === "destroyed")) return;

sawDocument is a one-way latch, and REPLACEMENT is detected only at line 336 (batch.hasCreated && batch.hasDestroyed). So if a document swap ever arrived as two commits (a clear-then-fill), batch 1 dies at line 198 and batch 2 is create-only with sawDocument === true, falling through both branches into the trailing "cross-editor paste" case: no placement, and phase is never set to "navigating" - so the refill's own settle is reported as a user move at verse 0.

Reproduced: the single-batch control (setEditorState) emits nothing; split across two commits it emits {GEN, 1, 0} while the host is at 1:2.

Trigger: none today. I checked all four candidate paths and every one is single-commit or unmounts instead:

  • LoadStatePlugin wraps one editor.setEditorState() - a single BookNode:destroyed,created batch (verified).
  • Undo/redo restores via a single setEditorState, and CLEAR_HISTORY_COMMAND fires after every load anyway, so undo never spans a swap.
  • No production code calls $getRoot().clear().
  • The host unmounts the editor rather than blanking it - renderEditor() swaps in a spinner or an error div when the usj is empty or the book doesn't exist, and a remount is a MOUNT, not a split swap.

So this is an undocumented invariant rather than a defect. I'm recording it because a split swap would be silently misclassified as the documented "cross-editor paste" branch, which is exactly the sort of thing a future LoadStatePlugin change could reintroduce with no test catching it. A sawDestroy flag set by the destroy-only batch and consumed by the next create-only batch would close it.


Happy to open separate issues for any of these, or to contribute failing tests (I have working repros for all four), if that's more useful than a comment. And happy to be told L2 and L4 are unreachable by construction - you'd know the host's data guarantees better than I do.

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>
@irahopkinson

Copy link
Copy Markdown
Collaborator Author

L1: the code attribute is required for /id (BookNode) in USFM but I have no idea if projects exist that don't have it for any book.
L2: we mostly display per-chapter so we split USJ per chapter and send to the editor so only chapter 1 has a BookNode and all the rest are bookless. With the new Text Collection in Simple we are also using the editor component to display a verse at a time so definitely bookless also.
L3 & L4: are your call @lyonsil since you know this new state machine better than anyone.

@irahopkinson irahopkinson left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@irahopkinson made 3 comments and resolved 2 discussions.
Reviewable status: 2 of 3 files reviewed, 18 unresolved discussions (waiting on katherinejensen00 and lyonsil).


packages/platform/src/editor/ScriptureReferencePlugin.tsx line 340 at r1 (raw file):

Previously, katherinejensen00 wrote…

NIT Claude Finding

The (a)(d) cross-references in onDocumentChanged are used in prose but never anchored to their code branches.

The REPLACEMENT branch says "The correction below (d), if any, runs after." The book correction block says "Runs after (a)–(c); under (a) phase is already 'navigating' here." But no comment in the code is actually labeled // (a), // (b), etc. — a reader has to manually count branches to decode the letter scheme. If (a) is REPLACEMENT, (b) is MOUNT, (c) is the paste/undo no-op, and (d) is the book correction block, that can be inferred, but it takes real effort. This predates your PR, so it doesn't need to block merge — but a one-liner label at the start of each branch (// (a) REPLACEMENT: ..., etc.) would make the cross-references self-evident.

Done.


packages/platform/src/editor/ScriptureReferencePlugin.tsx line 375 at r1 (raw file):

Previously, katherinejensen00 wrote…

NIT Claude Finding
$moveCaretToVerseStart is the only named handler in the file without a JSDoc, and it's the most complex one.

Every other handler in the file — onPropChanged, consumePendingEcho, $resolvePosition, onDocumentChanged, schedulePlacingCaretAtVerseStart, onSelectionSettled, positionMatchesScrRef, verseInRangeSafe, positionToScrRef, report, refsEqual, onUserInteracted — has a function-level JSDoc comment. $moveCaretToVerseStart doesn't, and it's the function with the most state assumptions: it must be called inside a Lexical editor-state update, it has an early-return when the caret is already in a verse range, and it handles the "no verse node, fall back to para" case. The file-top HOW-THIS-PLUGIN-WORKS block explains the overall algorithm but doesn't substitute for a function signature–adjacent description that callers read in context. Since this PR renamed the function and moved it, this is a good moment to close that gap with even two or three sentences.

Done.

}

/** First BookNode of the root (documents put it first; strays after content are ignored). */
function $getFirstBookNode(): BookNode | undefined {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

- 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>

@lyonsil lyonsil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I included L3 and L4 out of completeness, but I don't think either is strictly needed. They both require the plug-in and editor to do things that neither does today. Fixing L4 is also potentially part of a larger refactor to slightly simplify the state machine further that I previously chose to defer given time constraints and complexity.

:lgtm: - Thanks for the clean ups! I think these updates will make the new plug-in code easier to understand.

@lyonsil reviewed 3 files and all commit messages, made 1 comment, and resolved 18 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved.

@irahopkinson irahopkinson merged commit d97560e into main Jul 16, 2026
6 checks passed
@irahopkinson irahopkinson deleted the cleanup branch July 16, 2026 01:27
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