Skip to content

Close editor-support gaps: IME safety, writer tests, Tiptap, Outlook and Notion - #476

Merged
bartekplus merged 38 commits into
masterfrom
editor-support-gaps
Oct 5, 2026
Merged

bartekplus merged 38 commits into
masterfrom
editor-support-gaps

Conversation

@bartekplus

Copy link
Copy Markdown
Owner

Summary

This PR closes the editor-support gaps that an audit found in tests, IME safety, docs and coverage of high-traffic sites. The work is in six phases. Each real bug has its own fix: commit with a regression test that failed before the fix.

  1. Docs agree with the code. The capability matrix now matches the code (Draft.js insert-fragment, Quill native input, typing paths for Lexical, Draft.js, CKEditor 5 and Trix, CodeMirror 5). A fingerprint without its editor permits only an attempt. A new test proves that each of the ten typing-path fingerprints refuses the write.
  2. IME safety. Trix and Slate could write during an IME composition. A shared composition tracker now runs in both worlds and at all times. Every model, bridge and DOM-editor writer refuses during a composition.
  3. Direct writer unit tests for Lexical, Draft.js, Trix, TinyMCE, CKEditor 4, Froala and Summernote, with the real libraries in JSDOM: detection, one replacement, one Undo step, refusal after a change, refusal during composition.
  4. Typing e2e matrix. Draft.js, Trix, CKEditor 4, Froala, Summernote and bundled Quill 1/2: mid-line, second paragraph, list item, snippet and caret position.
  5. Top frameworks.
    • Tiptap fixture with the real @tiptap/* packages (dev dependency only).
    • Quill: an accepted word is its own Undo step. A bundled Quill 2 keeps the documented limit.
    • CKEditor 5: typing next to an inline image.
    • Partial read: documented as unsafe, with a test. A short lag at Review start no longer makes a model editor Review-only for the whole session.
  6. Large surfaces (docs/editor-surfaces.md).
    • Outlook on the web: the generic path lost edits on Undo and Redo. A new RoosterJS snapshot writer makes each write one Rooster Undo step. The fixture uses the real roosterjs package (dev dependency only).
    • Notion: each block leaf is a field. A write needs the caret already in that leaf, and the result is read back after Notion's input handling.
    • Gmail: keeps the generic path, as the maintainer decided.

Other bugs found and fixed

  • Stale predictions were written after the page moved the caret.
  • Escape reached the page after it closed the popup. In Notion, this selected the block and dropped the next keys.
  • Google Docs lost the "Apply fixes individually" note.
  • From the live check:
    • Outlook Undo left the typed letters selected.
    • Notion's DOM lock fought FluentTyper's attributes on the block leaves.

Constraints kept

  • No new permissions.
  • No network calls or telemetry.
  • The layer boundaries stay the same.
  • The manifests stay separate.
  • No copied code.
  • A fingerprint alone grants no write.

Test plan

  • bun run check
  • bun run test: 97,576 + 42 pass
  • bun run check:e2e:coverage: 268 behaviors
  • bun run test:e2e:full (Chrome): 262 pass, 0 fail
  • bun run test:e2e:full --platform=firefox: 257 pass, 0 fail
  • Live check in a signed-in Chrome with the dev build: Outlook (new empty draft, never sent) and Notion (own test blocks, removed afterwards). 11 of 12 checks passed, and the two bugs that the check found are fixed.
  • Still open: a live Firefox check of Outlook and Notion, and a real IME composition on the live sites.

🤖 Generated with Claude Code

bartekplus and others added 25 commits October 5, 2026 12:18
- review-reference.md: Draft.js Review pushes "insert-fragment"
  (ReviewModelEditors.ts:279), not "spellcheck-change". Draft.js merges
  only insert/backspace/delete-character pushes
  (draft-js EditorState.js:533-536), so each push is its own undo step.
- editor-capabilities.md: Quill typing is native input
  (ContentEditableAdapter.ts:780-791, :259-305); the bridge has no Quill
  typing branch (HostEditorMainWorldBridge.ts:520-544).
- Lexical, Draft.js, CKEditor 5 and Trix each have a typing path:
  Draft.js/Trix through MODEL_TYPING_SELECTOR (HostEditorBridgeProtocol.ts:15,
  ReviewModelEditors.ts:509-550), CKEditor 5 through model.change in the
  MAIN world (HostEditorMainWorldBridge.ts:229-312), Lexical through a
  synthetic beforeinput (ContentEditableAdapter.ts:258-266).
- Fingerprint-only typing: EditorCapabilities.ts:72-78 (since 6ad9fb9)
  permits only an attempt. The doc now splits "fingerprint without a
  typing path" (unverified-writer) from "typing-path fingerprint without
  its editor" (shown, each write refused). A new test in
  ContentEditableAdapter.test.ts proves that each of the ten typing-path
  fingerprints refuses the write without its editor.
- Add CodeMirror 5 typing (HostEditorControllerUtils.ts:44-60, :150-187).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Trix Review adapter returned "composing: () => false". Thus the
writer did not see an IME composition. During a composition, Review
read the text that the IME had not committed. Review Apply and typing
(replaceModelBlock through MODEL_TYPING_SELECTOR) could write into the
composition. Trix has no public composition state, and its internal
Level2InputController sets "composing" only at the first
insertCompositionText.

The MAIN-world bridge now records the target of each compositionstart
and compositionend (recordComposition in HostEditorControllerUtils.ts).
It clears the record when the bridge is turned on or off.
isComposingIn(root) is true while a composition runs in the root, in a
descendant or in an ancestor. The Trix adapter returns
isComposingIn(root) (ReviewModelEditors.ts:405). Its snapshot then
refuses Review reads, Review writes and typing writes.

Test: HostEditorCKEditor5Bridge.test.ts, "Trix refuses Review reads,
Review writes and typing writes while composing". Before the fix,
readReviewModel gave the text during the composition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Slate bridge (readSlate, applySlate, slateBlockContext and
replaceSlateBlock) had no composition guard. During a composition the
DOM holds text that the Slate model does not have yet. Thus Review read
the uncommitted text, and a Review fix or a typing completion could
write into the composition.

slate-react keeps its composition state in a WeakMap of the page's own
bundle (ReactEditor.isComposing), which the bridge cannot reach.
owningSlate() now refuses the editor while isComposingIn(root) is true
(SlateEditor.ts:99). All Slate reads and writes go through it.

Test: SlateEditor.test.ts, "an IME composition blocks Review reads,
Review writes and typing writes" (real slate-react). Before the fix,
readSlate gave the text during the composition. The test file also
defines the HTMLInputElement and HTMLTextAreaElement globals, because
the slate-react composition handlers read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring a composition

The content-script Review targets keep a "composing" flag. The
ReviewController sets it on compositionstart and compositionend, but it
adds these listeners only when Review opens. The flag started as false.
Thus a Review that opened during a composition read the uncommitted
text, and Apply wrote into the composition. This applies to text
controls, plain contenteditable fields, Quill beforeinput writes and
the DOM-model editors.

The content runtime now records the composition target from its start
(ContentRuntimeController.ts, recordComposition on document capture).
It uses the same helper as the MAIN-world bridge; each world has its
own module instance. When Review opens, the target flag starts as
isComposingIn(element) (ReviewController.ts:315).

Test: ReviewAdapters.test.ts, "a review opened during an IME
composition writes nothing until the composition ends". Before the
fix, Review showed "Issues: 1" during the composition and Apply wrote
"We saw the cat.". Now Review shows "Paused while you compose text."
and writes only after compositionend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ites during composition

Three bridge writers had no composition guard: the CKEditor 5 typing
path (applyCKEditor5BlockReplacement), the CodeMirror 5 typing path
(applyBlockReplacement) and the native edit of the DOM-model editors
(applyDomEditor). The upstream typing guard (entry.isComposing) blocks
them in practice. These guards are extra safety: no bridge writer can
change the text during an IME composition.

- CKEditor 5 typing: readCKEditor5Block refuses while
  view.document.isComposing is true. It also refuses the block context.
- CodeMirror 5: applyBlockReplacement refuses while isComposingIn(elem).
- TinyMCE, CKEditor 4, Froala, Summernote: applyDomEditor refuses while
  isComposingIn(elem). The check runs before "begin" and again
  immediately before insertText.

Tests (HostEditorCKEditor5Bridge.test.ts, "IME composition"): "CKEditor 5
typing refuses to write while its view composes", "a CodeMirror 5
controller refuses to write while the field composes", "a DOM-model
editor (TinyMCE) refuses its native edit while the field composes".
Before the fix, each write gave "applied": true during the composition.

Composition guard of each writer (Phase 2 record):
- ProseMirror Review and typing: ProseMirrorEditor.ts:131 (view.composing
  in readProseMirror; applyProseMirror and replaceProseMirrorBlock call it)
- Quill Delta Review path: QuillEditor.ts:77, :122, :175
  (selection.composing)
- Quill and Lexical beforeinput, plain contenteditable, text controls and
  DOM-model Review writes (content script): ReviewTargets.ts:338, :376,
  :526, :551, :621; flag set at ReviewController.ts:315-324
- Slate Review and typing: SlateEditor.ts:99 (isComposingIn in owningSlate)
- Gutenberg Review and typing: GutenbergEditor.ts:379 (fieldFor)
- Lexical Review: ReviewModelEditors.ts:101 (editor.isComposing())
- Draft.js Review and typing: ReviewModelEditors.ts:205
  (isInCompositionMode)
- CKEditor 5 Review: ReviewModelEditors.ts:357 (view.document.isComposing)
- Trix Review and typing: ReviewModelEditors.ts:405 (isComposingIn);
  model adapters checked at ReviewModelEditors.ts:458
- CKEditor 5 typing: HostEditorMainWorldBridge.ts:231
- CodeMirror 5 typing: HostEditorMainWorldBridge.ts:345
- TinyMCE, CKEditor 4, Froala, Summernote typing:
  HostEditorMainWorldBridge.ts:366
- All typing writes, upstream: SuggestionTextEditService.ts:119, :505,
  :812 (canEdit -> refreshInteraction, SuggestionEntrySession.ts:150)
- Composition record: HostEditorControllerUtils.ts (recordComposition,
  isComposingIn); MAIN world at HostEditorMainWorldBridge.ts:408, :453;
  content script at ContentRuntimeController.ts:128

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MAIN-world bridge recorded IME composition events only while it was
turned on. A composition that started before the bridge turned on was
not seen. The Trix, Slate, CodeMirror 5 and DOM-editor writers could
then write during that composition.

The bridge now listens for compositionstart and compositionend at all
times, through the same listener list that survives document.open()
(HostEditorMainWorldBridge.ts). Remove the unused setCompositionTarget.

Test: "a composition that starts while the bridge is off still blocks
writes after it turns on" (tests/HostEditorCKEditor5Bridge.test.ts).
It failed before the fix with applied: true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each writer now has a direct unit test with the real vendored library
in JSDOM. No stub of an editor library is used.

- tests/ReviewModelEditors.test.ts: Lexical, Draft.js and Trix.
- tests/ReviewDomEditors.test.ts: TinyMCE, CKEditor 4, Froala and
  Summernote, through the real InjectedHostEditorPageBridge.

For each editor the tests check: detection (and refusal of a
fingerprint without its editor), one replacement with formatting and
caret kept, one host Undo step that keeps earlier typing, refusal when
the content changed after the read, and refusal during an IME
composition with a write after it ends. Draft.js and Trix also test the
typing path (replaceModelBlock).

The test files polyfill only missing JSDOM features (customElements,
ElementInternals.setValidity/setFormValue, InputEvent.getTargetRanges,
Range rects, matchMedia, canvas). tests/modules.d.ts gets the Draft.js
types the tests use and a "trix" module declaration.

Known limits of the tests: the Lexical composition case is also caught
by the model/DOM text check; Draft.js runs on React 19 in the unit test
(the e2e fixture uses React 18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved away from

A page can focus an editor and then move the caret by script, for
example to the end of the text. FluentTyper asked for predictions at the
old caret place on focus. The response then showed at the new caret,
and Space or Tab wrote the stale word there ("We saw" became "We We").

Cause:
- handlePredictionResponse checked only requestId. A caret move without
  input does not change requestId.
- reconcileSelection kept a visible menu when the caret moved forward
  over unchanged text ("anything".startsWith("") is true).

Fix (SuggestionEntrySession.ts):
- Drop a response when the text before the caret is no longer a prefix
  of the request text and the next character changed too. A caret behind
  the request is still accepted, because a host can report a stale
  selection.
- A longer before-caret text keeps the menu only when the text changed.

Tests (tests/SuggestionEntrySession.test.ts), both fail without the fix:
- "a response for a caret place that the page moved away from is not shown"
- "a caret move without a text change dismisses a contenteditable menu"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For Draft.js, Trix, CKEditor 4, Froala, Summernote and the bundled
Quill 1 and Quill 2 fixtures (no window.Quill global), add e2e cases:
- acceptance mid-line, in the second paragraph and inside a list item,
  with the caret after the accepted word;
- snippet expansion in one native undo step;
- the seven fixtures join the "caret after the inserted word" list.

The fixtures add a second paragraph and a ul/li item with
?reviewSeed=blocks. Without the parameter the seed is unchanged.
A shared helper (openTypingEditor, TYPING_EDITORS) replaces the copied
setup in the typing and Review tests.

Editor limits that the tests wait for (no skips):
- Trix 2.1.19 loses its block element when Backspace deletes all text.
- Draft.js with React 18 renders after a model write; a key typed
  before that render goes into the old DOM.

coverage-matrix.json: rich_editor_typing_positions,
rich_editor_snippet_expansion, stale_predictions_after_caret_move.

Chrome: 57 targeted cases and the full suite (229) pass.
Firefox: not run in this session (see the follow-up commit or report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… up with the DOM

When Review opened on a Draft.js field whose DOM was ahead of the model,
the session stayed Review-only for its full life. The ReviewTargets.ts
constructor set "model-editor" one time only.

Now resolveModelWriter() changes the writer to "host-model" only when the
full readReviewModel check passes. The poll of 1 s calls it. The
Review-only note updates on paint. No write check changed.

A partial read stays refused: a model write renders the block again and
can remove text that only the DOM has.

The test "a DOM ahead of the model when Review opens gets Apply after the
model takes the text" failed before the fix with apply:false/bulk:false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quill history merged the accept into the typing of the last second. Thus
one Undo also removed the typed text.

Fix: a bridge action quillHistoryBoundary (quill.update("user"), then
history.cutoff()) runs before and after the native write, for .ql-editor
only. It adds no write and skips no check.

window.Quill and a bundled Quill 1 get a separate undo step. A bundled
Quill 2 keeps the limit, because it keeps its instances in a
module-private map.

The tests failed before the fix. For example, in the quill1 e2e test the
model after Undo did not have " w".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A block with an inline image had no model mapping. Thus the accept went
back to the CKEditor typing batch, and one Undo also removed the typed
text. A collapsed edit at a softBreak made an inverted model range:
writer.remove threw, and the write was refused (a latent fault). The
attributes of an image next to the text could go onto the text.

Fix:
- Map only textless inline objects (schema object and inline, empty DOM
  text).
- Refuse an edit that contains an object, or that has an object between
  the edit and the caret.
- Use one model offset for a collapsed edit.
- Take attributes only from $text or $textProxy.

These tests failed before the fix:
- "block context next to an inline image has the text around it and the
  caret"
- "a replacement after an inline image uses model offsets and keeps the
  image"
- "an insertion right after an inline image does not take the image's
  attributes"
- "a collapsed insertion at a softBreak goes to the caret's side of the
  break"

These tests check the refusals:
- "a replacement across an inline image is refused and the model stays"
- "a word before an inline image is refused when the caret is after the
  image"
- "an inline object that shows text is refused for context and writes"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the real @tiptap/core, @tiptap/starter-kit and @tiptap/pm 3.31.4 as
dev dependencies. The lockfile keeps one prosemirror copy.

The Tiptap fixture is in REVIEW_EDITORS and TYPING_EDITORS. These tests
pass on Chrome (6 pass):
- Review Apply and Fix all, with one Undo.
- Typing and Tab accept, with one Undo.
- Accept mid-line, in the 2nd paragraph and in a list item.
- Snippet expansion.

Firefox is pending: it cannot start in this session.

The docs and the coverage matrix tell that Tiptap uses the ProseMirror
path. This commit also removes a space from the Trix section comment in
tests/ReviewModelEditors.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Google Docs applies one fix at a time (capabilities bulk: false). The
open note said so, but each paint set the Docs notes to only
"review_cap_docs" or to nothing. Thus the user did not see why Fix all
was unavailable.

The Docs paint now adds writeCapabilityKeys(target), as the other
targets do (ReviewController.ts).

Test: the Docs controller test in tests/ReviewAdapters.test.ts now
checks the note after a paint. It failed before the fix: the notes held
only the Docs highlight note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Typer popup

Escape closed the popup, but the page also got the Escape. On Notion,
Escape selects the block, and the next keys typed nothing (live probe:
four keys were lost).

Now FluentTyper consumes Escape only when a popup or an inline
suggestion is visible and FluentTyper owns the keys. In all other
conditions, the page gets Escape.

The unit test failed before this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On live Outlook, the generic path lost an edit: one Undo removed two
steps, and Redo did not restore the edit. RoosterJS records only its own
input in its undo snapshots.

The new Transaction in ReviewDomEditors.ts uses these steps:
takeSnapshot(), the validated native edit, takeSnapshot() and a
contentChanged event. The MAIN-world bridge finds the editor in
window.__ROOSTERJS_DEVTOOLS_EDITORS__ (roosterjs 9.59 and later).
Without this list, an identified Rooster editor is Review-only.

The writer refuses an edit during an IME composition, in shadow edit,
without focus and without a range selection. When "end" does not give
an undo step, the result is unverified. The TinyMCE and CKEditor 4
"begin" and "end" functions now return void.

The e2e fixture uses the real roosterjs package (9.60.0, dev
dependency). 8 Chrome e2e tests pass (4 failed before). The Firefox run
is pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Notion page root is a canvas, as the Gutenberg canvas is. Each block
leaf is the field for typing, block context and Review.

A write occurs only when these conditions are true: the caret is
already in that leaf, no composition runs, and the range is in the
leaf. After the write, FluentTyper reads the leaf text again and
watches the leaf for a revert for 1 s.

Review applies one fix at a time in each leaf (bulk: false).

The e2e fixture is a synthetic page and contains no Notion code.
5 Chrome e2e tests pass (4 failed before). The live check on Notion is
pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the Phase 6 report. For each surface, it gives the editor model,
the safety of the generic path, the verified writer, the risks and the
live checks that are open.

Gmail keeps the generic path (maintainer decision).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: in Outlook on the web, Undo of a word that Tab accepted
restored the text, but the typed prefix stayed selected. The next key
replaced it ("It was bi" + Tab, Undo, "g" gave "It was g"). Undo of a
typing autocorrect put the caret at the start of the word.

Cause: applyDomEditor selects the replaced range, then begin() records
the host undo step. RoosterJS takeSnapshot() records the live DOM
selection, and Undo restores the selection of that snapshot. Froala,
Summernote and CKEditor 4 record the selection in their undo step in the
same way. TinyMCE restores the bookmark of its last beforeChange(),
which is the start of the user's typing.

Fix: the typing request sends the user's caret as a text offset.
applyDomEditor puts this caret back before begin(), then selects the
range again for the native edit. The TinyMCE begin() also calls
beforeChange(), so it records the same caret. All refusals stay.

Test: tests/ReviewDomEditors.test.ts (real CKEditor 4, Froala,
Summernote and RoosterJS in JSDOM): after a typing edit with the caret
after the word or after the next space, Undo gives a collapsed caret at
that offset. Before the fix, the selection was not collapsed. A Review
fix keeps the caret too. tests/e2e/full.e2e.test.ts: for TinyMCE,
CKEditor 4, Froala, Summernote and RoosterJS, after accept and Undo the
selection is collapsed and the next key extends the typed word. Before
the fix, all five failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: on Notion, the console showed many warnings "Reverting
mutation of attribute data-ft-suggestion-id" and others. No block leaf
kept a data-ft-* attribute. The MAIN-world early Tab bridge did not
find FluentTyper's state, so Tab used only the isolated-world handler.

Cause: resolveSuggestionStateHost returned the leaf. Notion's DOM lock
removes each foreign attribute of a leaf at once. FluentTyper has no
write loop: its DOM observer does not watch data-ft-* attributes, and
it writes them only on attach and on each menu render. Thus each
FluentTyper write gave one Notion revert. The page root is not locked.

Fix: resolveSuggestionStateHost returns the Notion page root for a
block leaf. The root is shared by all leaves, so the menu render also
writes the entry id of the leaf that shows the menu. The early Tab
bridge finds the root in the event path, as before.

Test: tests/NotionEnvironment.test.ts: after attach and render, no leaf
has a data-ft-* attribute, and the root holds the state with the shown
entry id. Before the fix, each leaf had six attributes. The Notion-like
e2e fixture now reverts each foreign leaf attribute, as Notion does. A
new e2e test: one key press gives no locked leaf attribute (before the
fix: 2), and Tab goes through the early bridge (before the fix: 0
requests). The other Notion-like tests also pass with the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record the live check in Chrome: Outlook checks 1 to 6 and Notion
checks 7 to 11 pass. Name the two bugs that the check found and their
fixes. Record the limits that stay: no Review button on a one-line
Notion block (the launcher needs a field of 36 px height), one Notion
Undo can remove typing and an autocorrect together, and the Outlook
"To" field gets first-word capitalization. Only a real IME composition
and Firefox stay open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: In the full Chrome suite, the test "Contenteditable keeps
surrounding rich formatting when accepting a suggestion" failed in
shard 1/6. No suggestion showed after the test typed "h". The test
passed alone.

Cause: The Slate inline test sets inline suggestions on. On Chrome,
notifyConfigChange opens the options page. With inline suggestions
on, that page stores prefixOnlyMode=true. The Slate test reset only
inline suggestions, so prefix-only mode stayed on. The rich test puts
the caret directly before "next". In prefix-only mode, Presage gives
no prediction there. The two tests are in the same shard since commit
5e1019c added one test before them. The pair also fails on master.

Fix: The rich test sets inline suggestions and prefix-only mode to
off. The Slate test resets prefix-only mode in its finally block.

Test: The Slate test and the rich test pass in one process two times.
Before the fix, the pair failed on this branch and on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: on Firefox, "trix typing accepts a prediction with formatting
kept and one native undo step" failed in each run. The typed " w" went to
the start of the text, and the accept changed "wWe" to "wee ".

Cause: Trix 2.1.19, not FluentTyper. The test clicked the editable and
pressed End. In a focused Firefox window, the click puts the DOM caret
at the end of the text. Then Trix gets the focus event before it reads
that selection. EditorController.compositionControllerDidFocus sees no
location range (isFocusedInvisibly) and calls setLocationRange at
index 0, offset 0. Firefox does not move the caret from that place on
End. A probe without the extension shows the same result: the text
becomes " wWe saw teh cat and teh dog.". The test also failed on master
(2 of 2 runs), before commit a069dd7.

Fix: put the caret after "dog." with the placeCaretAfter helper, as the
other typing tests do. The test does not use a click and End.

Test: Firefox, the typing acceptance cases pass 3 times in a row. The
full Firefox suite (257) and the full Chrome suite (262) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full e2e suite passes on Firefox (257 pass) and Chrome (262 pass).
Only a live Firefox check of Outlook and Notion stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

bartekplus and others added 4 commits October 5, 2026 17:13
Symptom: In inline mode, an exact-match prediction (an empty suffix) or a
suffix of only white space shows no ghost. Escape was still consumed. Thus a
page Escape (for example, to close a dialog) did nothing, and the user had
to push Escape two times.

Cause: The keyboard handler decided that a suggestion showed when
entry.inlineSuggestion was set. The inline presenter keeps that value armed
for Tab also when it renders no ghost.

Fix: The handler now asks if a ghost for this entry is in the page
(InlineSuggestionView.hasForEntry) or if the menu is visible. The presenter
renders no ghost for a suffix of only white space. Tab acceptance does not
change.

Test: SuggestionKeyboardHandler.test.ts "Escape with an armed inline
suggestion that shows no ghost goes to the page" and
InlineSuggestionPresenter.test.ts "keeps a suggestion armed without a ghost
when only white space is left to preview". Both failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: When Review opened during an IME composition in a Notion block,
Review stayed "Paused while you compose text." after the composition ended.
It did not read the block again for the full session.

Cause: The controller set target.composing from isComposingIn(leaf), and
listened for compositionstart and compositionend on the leaf. Notion keeps
focus on its page root, so these events fire on the root. They do not go
down to the leaf. Thus nothing cleared the flag.

Fix: For a Notion leaf, the controller listens for the composition events on
its page root (notionRootOf(element) ?? element). Review opened during a
composition still reads and writes nothing until the composition ends.

Test: ReviewAdapters.test.ts "a review opened during an IME composition in
a Notion block reads again when it ends". Before the fix, the status did not
change to "Issues: 1" after compositionend on the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Review reference said that a live check on Notion was pending. The
Chrome live check passed (see the live check results in
editor-surfaces.md). The text now says that and links to the results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: In TinyMCE, CKEditor 4, Froala, Summernote and RoosterJS, push
Enter, then accept a next-word prediction (an empty token) at the start of
the new block. After one host Undo, the caret went to the end of the block
before it, and the next typing went there.

Cause: The bridge records the user's caret in the host undo step before the
write. It got the caret as a text offset and mapped it with textPosition,
which takes the first text node that ends at that offset. A block start and
the end of the block before it have the same text offset.

Fix: When the user's caret is at the end of the replaced range (the usual
case), the bridge collapses to the end of the live replaced range. It uses
the offset mapping only for other carets. The write checks do not change.

Test: ReviewDomEditors.test.ts "after a typing edit at the start of a second
block, Undo puts the caret back in that block" (CKEditor 4, Froala,
Summernote, RoosterJS with the real libraries). Before the fix, the caret
was in "We saw teh cat." after Undo, not in "Hi".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bartekplus and others added 9 commits October 5, 2026 17:17
Symptom: In Notion, when one block leaf detached (for example, when Review
suspended it for a write), the early Tab bridge stopped to claim Tab in the
other attached leaves of the page.

Cause: All Notion leaves use the page root as state host. detachHelper
removed all FluentTyper attributes from the state host, also when other
attached entries used the same host.

Fix: When another attached entry uses the same state host, detachHelper
keeps the attributes. If the host named the detached entry, it names the
other entry and sets the visible flag to false. The last entry that detaches
removes all attributes, as before.

Test: NotionEnvironment.test.ts "a Notion leaf that detaches leaves the
shared root state to the other leaves". Before the fix, data-suggestion on
the root was null after one leaf detached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: When Review opened during an IME composition in Slate or
ProseMirror, the target stayed "model-editor" (Review only, no Apply) for
the full session, also after the composition ended.

Cause: The bridge does not read Slate or ProseMirror during a composition,
so the constructor found no writer. resolveModelWriter tried again only
readReviewModel (Lexical, Draft.js, CKEditor 5, Trix), not the other kinds.

Fix: The constructor and resolveModelWriter use the same kind detection
(detect). resolveModelWriter accepts only a verified writer kind
(prosemirror, slate, host-model or host-dom). A fingerprint without its
editor stays Review only. No write check changes.

Test: SlateEditor.test.ts "a Review opened during an IME composition gets
the Slate writer after it" (real slate-react) and ProseMirrorReview.test.ts
"a Review opened during an IME composition gets the ProseMirror writer after
it". Before the fix, resolveModelWriter() gave false after compositionend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… wait

Symptom: In Notion, a Review fix gives focus back to the page root and waits
100 ms before it writes. When the user closed Review in this wait, the fix
was still written.

Cause: After the wait, apply() did not examine if the review was still open.

Fix: ContentEditableReviewTarget.dispose() (which ReviewController.close()
calls) now sets a disposed flag. After the Notion wait, apply() refuses with
"host-refused" when the flag is set. The other write checks do not change.
The Review reference now tells about this check.

Test: NotionEnvironment.test.ts "a Review fix in Notion writes nothing when
the review closes during the focus wait". Before the fix, the result was
"applied" and the leaf held "We saw the".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- recordComposition: set the target in one line. Only compositionstart
  and compositionend call this function.
- ReviewController: one capabilityKeys helper gives the keys, also the
  Google Docs key. createUi and paint use it.
- SuggestionMenuPresenter: compare only the entry id on the state host.
  A host that is not shared already holds the id of its entry.
- HostEditorPageBridge: quillHistoryBoundary returns nothing. No caller
  reads the result. ContentEditableAdapter makes one bridge for each write.
- Use the ReviewTransactionPhase type in the bridge protocol and bridge.
- NotionEnvironment: NOTION_REVERT_WINDOW_MS is private. Remove the test
  that compared the constant with its own value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use the shared helpers: setCaret, setCaretAtTextOffset,
  enableHostEditorBridge (now with an "enabled" parameter) and a shared
  "until" wait in tests/reviewTestUtils.ts.
- ReviewDomEditors.test.ts records each global change (browser features,
  window globals, library globals) and removes it in afterAll.
- NotionEnvironment.test.ts: the preload gives execCommand. Fake timers
  replace the real 1 s wait and stop the revert check timer of the
  typing write.
- Remove the Trix mock from the bridge test. The real Trix test now also
  checks the typing write during a composition. The "bridge is off"
  composition test uses a CodeMirror 5 controller.
- full.e2e.test.ts: one ModelEditor type, one "w" prediction wait, seeds
  from the fixture constants. Remove a Notion sleep that the revert
  counter makes unnecessary. Give the reason for the other sleep.
- Notion fixture: runs() reads the model. Remove the Redo stack, the
  undoDepth counter and the unused export.
- Coverage matrix: add the IME composition behavior and the Google Docs
  note behavior. Map the CKEditor 5 inline object row to tests that fail
  without the support. Mark the two guard tests in their descriptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- review-reference.md: the Notion checks are a numbered list. Each
  sentence has one claim.
- editor-capabilities.md: the CKEditor 5 inline object item has one
  claim in each sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom: Escape closed the suggestion menu, but the menu can open again
some time later. CI job "E2E Full (chrome, 1/2)" failed in the test
"Notion-like page: Escape closes the suggestion popup only". The menu
stayed visible for 5 s after Escape.

Cause: Escape called only clearSuggestions. It did not stop the
debounced prediction request, and it did not change the request id.
Thus the answer to a request that was on its way when the user pushed
Escape showed the menu again. On a slow machine, the answer to the " "
of " w" can show a word that starts with "w" first. Then the answer to
"w" comes after Escape.

Fix: Escape uses dismissEntry, as a click does. It stops the pending
request and the idle timers and increments the request id. The entry
stays active, and Escape goes to the page as before.

Test: SuggestionManagerRuntime.test.ts "Escape drops the prediction
answers that are still on their way". It failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While inline mode is on, the options page also stores prefix-only mode,
Tab acceptance and 10 suggestions. The tests turned off only inline
mode. Thus later tests ran with prefix-only mode and 10 suggestions.

- INLINE_MODE_OFF holds inline mode off and the defaults of its locks.
  restoreInlineMode() writes it. The inline tests and openTypingEditor
  use it.
- beforeEach writes INLINE_MODE_OFF and the space after an accepted word
  when an earlier test wrote settings.
- Cause of the order-dependent failure of "Inline suggestion prediction
  is inserted on TAB in #test-input": the CKEditor inline image test
  turns off the space after an accepted word and does not turn it on
  again. The inline test expects that space.
- The measurement unit test sets the enabled languages. An auto-detect
  test leaves only English and Arabic, and then the Polish step failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bartekplus
bartekplus merged commit c9e1f00 into master Oct 5, 2026
13 checks passed
@bartekplus
bartekplus deleted the editor-support-gaps branch October 5, 2026 16:53
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