Skip to content

PT-4537: Keep a visible caret in an empty verse - #16

Open
mattgetgen wants to merge 2 commits into
mainfrom
pt-4537-make-caret-visible-in-empty-verse
Open

mattgetgen wants to merge 2 commits into
mainfrom
pt-4537-make-caret-visible-in-empty-verse

Conversation

@mattgetgen

@mattgetgen mattgetgen commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Branch: pt-4537-make-caret-visible-in-empty-verse

Base: origin/main

Date: 2026-09-18

Review model: Claude Opus 5

Files changed: 6

Overview

An empty verse between two verses in one paragraph gave no visible caret. Arrowing across it left
the caret on the paragraph's element point, where Chromium returns zero client rects and nothing is
drawn, so the user loses their place and typing appears to go somewhere else.

The cause was not the rule that decides where a caret host is needed — that was already correct and
already firing. TextSpacingPlugin's trailing-space transform destroyed the host it made, in three
steps: the host's next sibling is a verse marker, so a space was appended; the host was then no
longer placeholder-only, so the guard's own strip-on-edit transform read that as the user typing and
removed the zero-width space; and the lone space left behind matched the transform's empty-verse
clause, which cleared it to nothing. Lexical collected the empty node and the caret fell back to the
element point. Two further gaps in the same behavior were fixed alongside it: a caret host is a
one-character text node, so the browser offered a caret position on each side of its zero-width
space and crossing cost a press at which the caret did not appear to move; and an edit that empties
the verse the caret is resting in announces nothing, because Lexical skips its selection-change
dispatch when the DOM selection already matches the one the edit applied.

Verified in Chromium against the platform demo, not only in tests: crossing the verse reports one
client rect and a caret height of 17 at every keystroke in both directions, two presses each way;
deleting a verse's text leaves a visible caret in the emptied verse; the delete still emits one
local USJ change carrying no zero-width space; and a single UNDO_COMMAND restores the text
cleanly.

API Changes

None. No exported symbol was added, removed, renamed or re-signatured — verified by diffing the
^export declaration lists of all changed source files against the merge base. Three behaviors
changed behind unchanged signatures:

  • EmptyVerseCaretGuardPlugin — additionally registers node transforms on ParaNode and
    ImpliedParaNode that materialize the caret host inside the same commit as the edit, deliberately
    without CURSOR_CHANGE_TAG.
  • ArrowNavigationPlugin — a new module-private $exitCaretHostForward step in the forward
    unmodified-key chain consumes one press when the caret is in a bare cursor host.
  • TextSpacingPlugin$textNodeTrailingSpaceTransform now exempts placeholder-only text nodes.

packages/platform/etc/platform-editor.api.md correctly needs no update.

Findings

Critical — Must address before merge

None.

Important — Should address before merge

  • The "deliberately untagged" decision was pinned by no test. Adding
    $addUpdateTag(CURSOR_CHANGE_TAG) inside the new transform left both full suites green, yet
    that mutation would suppress the user's own edit from USJ-change consumers.
    (fixed during review: the stranded-caret test now records the tags of the commit that
    produces the host and asserts CURSOR_CHANGE_TAG is absent; mutation now fails exactly that
    assertion and nothing else)
  • The ImpliedParaNode half of the new transform was completely uncovered. Changing
    PARA_KLASSES to [ParaNode] left both full suites green.
    (fixed during review: added "hosts the caret in an implied paragraph too"; the mutation now
    fails exactly that test)
  • A new arrow test was non-discriminating. "stays put when nothing follows the host" passed
    at the merge base and also passed with the if (!following) return false; branch it guards
    deleted.
    (fixed during review: it now asserts event.defaultPrevented === false, which is what
    distinguishes "left to the browser" from "handled to no effect"; deleting the branch now fails
    it)
  • Orphaned TSDoc on the public plugin. PARA_KLASSES was inserted between the plugin's
    TSDoc block and export function EmptyVerseCaretGuardPlugin. TSDoc binds to the next
    declaration, so the plugin's doc documented a private constant and the constant's one-liner
    became the doc published in dist/index.d.ts. Flagged independently by two analysis passes.
    (fixed during review: constant moved above the doc block, given its own wrapped doc, and the
    plugin's doc gained a paragraph naming both arrival routes, since a second entry point was
    added)
  • The realistic-plugin-set gap the defect came from was only narrowed. The new shared-react
    tests raise the mounted set from one plugin to two; the real editor mounts ~30.
    (fixed during review, partially: added packages/platform/src/editor/emptyVerseCaretHost.test.tsx,
    modelled on the sibling trailingNoteCaretHost.test.tsx — real USX through the production
    adaptor, CharNodePlugin + MarkerEditPlugin + TextSpacingPlugin + the guard, in the
    marker-hidden view where these hosts exist. Both its tests fail without the fix. See
    "Suggested Review Focus" for what it still does not cover.)
  • Shift+arrow can never get the one-press crossing, and is in fact hard-stuck.
    $exitCaretHostForward is gated on !hasModifier, and the only shift-aware path,
    $extendOneVisibleStop, is gated on markerMode === "editable" — which is exactly the mode
    where these hosts cannot exist, because an editable verse marker is a VerseNode extends TextNode and the rule declines. Measured in Chromium: shift+ArrowRight from inside a host
    does not move the selection at all, across four presses, with anchor and focus both pinned at
    the host's offset 0. Controls confirm shift+arrow works normally in ordinary text, from the
    end of the preceding verse, and leftward from the host. Left open deliberately — see Interview
    Notes.
  • Extending a selection out of a host removes the node the anchor sits in, and needs a
    deliberate decision recorded at the sync site.
    (Author: that decision is already recorded
    and tested in unchanged code — EmptyVerseCaretGuardPlugin.test.tsx:151-155 states the host
    must be removed when a range selection spans the verse, because the clipboard path serializes
    the node tree and has no placeholder awareness. It is ratified behavior for copy/cut
    correctness. The fair residue is that the new transform makes hosts appear from an ordinary
    delete, so this path now sees more traffic; recorded here rather than changed.)

[Author response: four of the six were fixed during the review, each re-verified by mutation. One
was dismissed with a citation to the existing ratified behavior. The shift+arrow finding was
confirmed by measurement, found to be worse than reported, and deliberately left for its own ticket.]

Minor — Consider

  • $emptyVerseNeedingHost's TSDoc still says "Read-only: call inside
    editor.getEditorState().read()", but the new node transform calls it from inside
    editor.update(). The calling-context line is treated as load-bearing in this repo.
  • PARA_KLASSES.filter((klass) => editor.hasNodes([klass])) diverges from the convention in
    this directory, where every other plugin makes one editor.hasNodes([...]) check and throws.
    The filter also cannot fire in practice, since both para types are in usjBaseNodes.
  • $exitCaretHostForward's if (!$isTextNode(following)) re-answers a question
    $caretHostAtBoundary owns; $placeCaretAtBoundary on the next line calls it internally.
  • $exitCaretHostForward skips exactly one non-text node without asking whether it renders no
    caret positions. Correct today only because the guard guarantees following is a verse
    decorator; $isVisibleAtom would make the invariant local.
  • Both the arrow rule and the spacing exemption identify a host by CONTENT
    ($isCursorPlaceholderOnlyText / isCursorPlaceholderOnly), not by tracked node key, while
    transientCaretHost.ts promises the opposite ("a zero-width space is legitimate content in
    some scripts (Thai/Khmer/Lao line breaks) and is never touched"). Matches existing precedent
    in four other files, so it may be a deliberate consistency choice — flagged so it is one.
  • No RTL variant for the new arrow rule, against this file's own convention (~17 existing cases
    pass "rtl"). The rule is direction-agnostic by construction — it sits in the
    isMovingForward branch — so the risk is low and this is coverage, not a defect.
  • No backward-direction test pins why the fix needs no mirror rule.
  • The TextSpacingPlugin exemption has no test in TextSpacingPlugin.test.tsx; its coverage is
    borrowed from the guard's test file.
  • The transform fires on every dirty para whatever caused the commit — a remote collab apply, a
    document load — not only the user edit its comment describes.
  • packages/platform/dist/ is tracked and is what paranext-core copies, and is not rebuilt on
    this branch, so the fix is not yet in the consumable artifact. Repo history suggests this is a
    dedicated follow-up commit (6920a2ae).

[Author response: left open. None change behavior; several are worth a follow-up sweep, and the
dist rebuild is a scheduling question rather than a defect.]

Template Propagation

Shared Regions Modified

None. No #region shared with markers exist in this repository — that convention belongs to
paranext-core.

Extension Config Changes

None — not applicable. This repo has no extensions/ directory and no extension-template lineage.

Positive Observations

  • Public API surface is genuinely unchanged: three behavior fixes with zero export churn.
  • Comments are forward-facing throughout — a sweep of every added line for ticket IDs, PR numbers,
    stage tags and change narration found none.
  • EmptyVerseCaretGuardPlugin's new body mirrors its sibling TrailingNoteCaretGuardPlugin exactly,
    so the two guards still read as one pattern; the repair is a node transform rather than an
    editor.update from inside a listener, which the repo's rules call out.
  • Good reuse of existing shared helpers rather than re-deriving placeholder logic.
  • The "assert on the commits, not the end state" technique in the stranded-caret test is a strong
    answer to a defect jsdom cannot reproduce: it re-evaluates the production rule on every commit, so
    it fails on jsdom's one-commit window and on the browser's permanent one.
  • An ordering hazard in TextSpacingPlugin was checked and is clean: isCursorPlaceholderOnly("")
    is false, so the new exemption cannot shadow the empty-verse cleanup below it or reintroduce the
    documented transform loop.
  • No flakiness or order-dependence in the shared-react tests: three shuffled runs of both changed
    files, 113/113 each time.

Interview Notes

Stated purpose. Make the caret visible in an empty verse (PT-4537), so a user arrowing through a
passage can see where the insertion point is and have typing land there.

Root cause was found by measurement, not inspection. The ticket asserted that ZWSP placeholders
were present in the DOM and that the host mechanism therefore existed but yielded no caret. That
premise is wrong: those ZWSPs are inside the verse decorator's own span
(ImmutableVerseNode.decorate() renders ZWSP + number + ZWSP for double-click selection), and
Lexical forces contentEditable='false' on decorator DOM, so they are not caret positions. The
ticket also reported three intermediate keystrokes and a keystroke with no selection at all; Chromium
shows one invisible stop and rangeCount never 0. The NO RANGE claim could not be reproduced
and should be treated as unconfirmed.

Design decision the reviewer should scrutinize. The new transform repairs inside the user's own
edit commit and deliberately does not tag it. Tagging would be the obvious defensive choice and is
wrong: CURSOR_CHANGE_TAG is in blackListedChangeTags, and DeltaOnChangePlugin is given
ignoreTags={blackListedChangeTags}, so it short-circuits the whole commit — the user's edit would
be suppressed, not just the host. The host needs no tag to stay out of the document, because the USJ
adaptor, the delta adaptor and the collab coordinates each exclude a placeholder-only text node by
content. This was verified end to end in the browser: the delete emits one local USJ change carrying
no zero-width space.

A rejected approach worth knowing about. The delete-arrival repair was first attempted from an
editor.registerUpdateListener, in a separate tagged update. It worked in the browser and broke
TrailingNoteCaretGuardPlugin's cross-guard test: the late SELECTION_CHANGE still carries the
pre-repair anchor, so the hook's stale-host pass deletes the host it has just created. That approach
was backed out rather than shipped.

transientCaretHost.ts's stale-host pass is the common factor in three separate problems seen
during this work: the race above, the shift+arrow stuck state, and the host being reaped under a
full-subtree transform pass in the new platform harness. It is unchanged by this branch. A reviewer
who wants one thing to look hardest at should look there.

Author does not understand / could not confirm: nothing was deferred to AI, but two things are
genuinely unverified rather than understood. (1) Whether the shift+arrow stuck state is pre-existing
or newly reachable — the mechanism lives in unchanged code and the trailing-note guard makes hosts
the same way, so it is probably pre-existing, but this was reasoned, not measured on the merge base.
(2) Whether a full-subtree transform pass of the kind the platform harness performs occurs in real
use; the browser showed the host surviving every real edit path exercised, so the synthetic pass may
be harsher than reality.

A wrong claim made and corrected during the review. An initial bisect concluded that
CharNodePlugin and MarkerEditPlugin each destroy the caret host. That was cross-test
contamination from module-level adaptor singletons. Run in isolation, neither is involved; the
remover is the guard's own stale-host pass.

In-Review Quality Check

All checks run against the scripture-editors worktree (this repo uses nx/pnpm, not paranext-core's
npm scripts):

  • libs/shared-react tests: 1660 passed, 1 skipped — clean, and stable across repeated runs.
  • packages/platform tests: 1698 passed — clean.
  • nx typecheck (shared-react, platform-editor): green.
  • nx lint: 0 errors. 4 pre-existing warnings in shared-react and 2 in platform-editor, all in
    files this branch does not touch.
  • prettier --check on all changed files: clean.

Two incidents worth recording. Running the nx test target regenerates committed build artifacts
(packages/utilities/dist, packages/platform/dist) via a raw build, which strips declarations —
the extract-api trap the repo's own CLAUDE.md documents; these were reverted each time and are not
in the diff. Separately, an untracked tsc --build output (packages/platform/dist/Editorial.d.ts)
was deleted during cleanup, which broke typecheck with TS6305; it was regenerated with
tsc --build --force and the committed dists that rebuild touched were reverted.

Suggested Review Focus

  • The untagged transform commit. The reasoning is in the code comment and in Interview Notes
    above; it is the decision most worth a second opinion, because getting it wrong silently drops
    the user's edits rather than failing loudly.
  • Shift+arrow from a caret host is hard-stuck (Important, left open). Decide whether it
    blocks this branch or gets its own ticket. A Playwright reproduction with controls exists.
  • transientCaretHost.ts's stale-host pass — implicated in three distinct problems during
    this work, all from the same "a late selection-change carries a stale anchor" shape.
  • What the new platform harness still does not cover. No single test exercises all three
    changes together, so the user story — arrow across an empty verse, see a caret, one press
    crosses it — has no end-to-end assertion. An attempt was made and dropped: jsdom re-resolves
    the DOM selection between a guard-made arrival and a key press, making it order-dependent.
  • RTL. The new arrow rule routes through isMovingForward, so it is direction-agnostic by
    construction, but there is no RTL test and none of this was exercised in an RTL project. Note
    the pre-existing gap documented on getEditorTextDirection: a project configured dir="auto"
    reads as "ltr", so this change adds one more consumer of that bug.
  • Verification that has not happened. The ticket's own repros — TPD Gen 1:3 and HPUXR
    2 Kings 2:7 — were not opened; verification was done in the engine demo app only. The change
    was also never checked against isBlockVerseLayout from engine #538, which the ticket asked
    for.
  • packages/platform/dist/ is not rebuilt, so paranext-core does not yet consume this fix.

🤖 Generated with Claude Code


This change is Reviewable

mattgetgen and others added 2 commits September 18, 2026 13:45
Arrowing across an empty verse between two verses in one paragraph left the
caret on the paragraph's element point, where Chromium returns zero client
rects and nothing is drawn. Saroj loses her place and her typing appears to go
somewhere else.

EmptyVerseCaretGuardPlugin was already detecting the boundary and inserting its
zero-width-space caret host correctly. TextSpacingPlugin then destroyed it in
three steps: the host's next sibling is a verse marker, so the trailing-space
transform appended a space; the host was no longer placeholder-only, so the
guard's own strip-on-edit transform read that as the user typing and removed the
zero-width space; and the lone space left behind matched the empty-verse clause,
which cleared it to nothing. Lexical collected the empty node and the caret fell
back to the element point. Exempting a bare cursor host from the trailing-space
transform, alongside the exemptions already there for notes, chars, typed marks
and attribute runs, is what makes the host survive.

Two further gaps in the same behavior:

A host is a one-character text node, so the browser offers a caret position on
each side of its zero-width space and both paint in the same place. Crossing the
verse therefore cost a press at which the caret did not appear to move, and only
going forward, so the two directions disagreed. Arrow traversal now treats a
bare host as the single insertion point it stands for, stepping over the marker
it was materialized against, which makes the crossing two presses each way.

An edit that empties the verse the caret is resting in strands it the same way,
and announces nothing: Lexical skips its selection-change dispatch when the DOM
selection already matches the one the edit applied, so the guard's
SELECTION_CHANGE route never runs. That arrival is now repaired from the edit
itself, as a node transform, so the host lands in the same commit and the caret
is never committed to a state it cannot be seen in. It is deliberately untagged:
CURSOR_CHANGE_TAG would suppress the whole commit for USJ-change consumers, and
that commit is the user's edit. The host needs no tag to stay out of the
document, since the USJ adaptor, the delta adaptor and the collab coordinates
each exclude a placeholder-only text node by its content.

Verified in Chromium against the platform demo, on an empty verse 3 between
verses 2 and 4 in one paragraph. Crossing it reports one client rect and a
caret height of 17 at every keystroke in both directions, where the middle stop
previously reported zero client rects; deleting a verse's text leaves a visible
caret in the emptied verse; the delete still emits one local USJ change carrying
no zero-width space; and a single undo restores the deleted text cleanly.

The new tests mount TextSpacingPlugin alongside the guard. Mounting the guard
alone, which is what the existing tests did, cannot reproduce any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t meets

The behavior fixes landed with tests that asserted less than they appeared to.
Mutation testing found three decisions unguarded: tagging the repair's commit
with CURSOR_CHANGE_TAG left both suites green, though it would suppress the
user's own edit from USJ-change consumers; dropping ImpliedParaNode from the
transform registration left both suites green; and the arrow test covering the
"nothing follows the host" branch passed with that branch deleted, because an
unmoved caret alone cannot tell "left to the browser" from "handled to no
effect".

Each is now pinned, and each was re-checked by re-applying its mutation: the
stranded-caret test reads the tags of the commit that produces the host, an
implied-paragraph case covers the second registration, and the decline case
asserts the press was not claimed.

Add a platform-level harness for the host, modelled on the one the trailing-note
guard already has: real USX through the production adaptor, with CharNodePlugin,
MarkerEditPlugin and TextSpacingPlugin mounted alongside the guard, in the
marker-hidden view — the only kind where these hosts exist, since an editable
verse marker is a TextNode and hosts the caret itself. Plugin-isolated tests
could not have caught the original defect; this is the shape that can.

Move PARA_KLASSES above the plugin's TSDoc. TSDoc binds to the next declaration,
so the block was documenting the private constant, and the constant's one-liner
was what reached dist/index.d.ts as the plugin's published documentation. The
plugin's doc now also names both arrival routes, since it had gained a second
entry point without saying so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant