[lexical] Refactor: Move event module globals into per-editor InputState#8809
Merged
Conversation
…tState Add InputState and CollapsedSelectionFormat interfaces with a createInputState() factory to LexicalEditor.ts. Replace all 15 module-level let variables in LexicalEvents.ts with per-editor editor._inputState.* access. Internal helpers that lack an editor parameter receive InputState directly. Export functions markSelectionChangeFromDOMUpdate and markCollapsedSelectionFormat now take editor as the first parameter.
Replace isFirefoxEndingComposition (boolean) and isSafariEndingComposition (boolean) with a single compositionPhase discriminated union: 'idle' | 'composing' | 'ending-firefox' | 'ending-safari'. Set compositionPhase to 'idle' in the Chrome $handleCompositionEnd path for state machine completeness. Clear handledSelectionCommandTimeoutId in resetEditor before replacing _inputState.
Replace the positional [number, string, number, NodeKey, number] tuple with a named CollapsedSelectionFormat interface (format, style, offset, key, timeStamp). Clear isSelectionChangeFromMouseDown on all document-registered editors when consuming the flag, preserving the previous single-global semantics when pointerdown bubbles from a nested editor.
Restore browser event-ordering comments in onCompositionEnd explaining why Firefox and Safari defer composition handling. Deduplicate the Safari compositionPhase reset in $handleKeyDown. Reuse the already-computed documentRegistrations lookup in the stale-flag clear-all. Add @internal markers to markSelectionChangeFromDOMUpdate and markCollapsedSelectionFormat. Update stale test comment referencing the removed isFirefoxEndingComposition variable.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
previously approved these changes
Jul 8, 2026
etrepum
left a comment
Collaborator
There was a problem hiding this comment.
Really liking the direction here, encapsulating all of this input state is going to make this so much easier to maintain
It looks like there's some kind of conflict after the imports update that caused the build to fail
etrepum
approved these changes
Jul 8, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
LexicalEvents.tshas 15 module-levelletvariables that track composition state, keyboard timing, selection-change flags, and other input-related signals. Because they're module globals, every editor instance on the same page shares them. That's fine with a single editor, but with nested or sibling editors a flag set by one instance leaks into another — for example,isSelectionChangeFromMouseDownset by a pointerdown in a nested editor stays stale in the parent because only oneselectionchangefires.This PR moves all 15 variables into a per-editor
InputStaterecord oneditor._inputState, so each editor tracks its own input state independently. This is the structural half of the composition FSM work discussed in #8793; a follow-up PR will add explicit authority transfer between composition phases.This is split into its own PR because the structural move (globals → per-editor fields) is a large mechanical diff that's easy to verify in isolation: every existing test should pass without modification. The follow-up PR builds on this foundation to add explicit phase-transition guards and authority transfer, which is a behavioral change that benefits from a separate review.
What changed
InputStateinterface +createInputState()factory (LexicalEditor.ts) — All 15 fields live here. The positional tuple[number, string, number, NodeKey, number]for collapsed selection format becomes a namedCollapsedSelectionFormatinterface. Three boolean composition flags plussafariEndCompositionEventDataare unified into acompositionPhasediscriminated union ('idle' | 'composing' | 'ending-firefox' | 'ending-safari') and a singlecompositionEndDatastring. Everything is@internal.Event handler migration (LexicalEvents.ts) — Every reference to the old globals now reads from
editor._inputStateor a localconst inputState = editor._inputStatebinding. Helpers likeisPossiblyAndroidKeyPresstakeinputStateas a parameter instead of closing over module state.Stale flag fix —
isSelectionChangeFromMouseDownhad the cross-editor bug described above. The new code clears the flag on all editors registered on the same document whenselectionchangefires.resetEditorcleanup — ClearshandledSelectionCommandTimeoutIdbefore replacing_inputStateto avoid orphaned timeouts.Caller updates (LexicalSelection.ts) —
markSelectionChangeFromDOMUpdateandmarkCollapsedSelectionFormatnow takeeditoras the first argument.Design notes
Why per-editor, not per-document or per-root? Composition state is inherently per-editor: each
LexicalEditorhas its own_compositionKey, update queue, and selection. Grouping input state at the document level would still allow cross-contamination between sibling editors on the same document. Per-root was considered but adds no benefit over per-editor since each editor already owns exactly one root.Why a flat record instead of a class?
InputStateis a plain object with no methods — just a bag of mutable fields read and written by event handlers. A class would add a prototype chain and constructor ceremony with no behavioral benefit. ThecreateInputState()factory keeps initialization in one place.What stays as module globals? The per-document registries (
rootElementToDocument,documentRegistrations,documentSelectionChange) are genuinely per-document — they track which editors are attached to a givenDocumentforselectionchangedispatch. Moving them to per-editor state would require cross-editor coordination that already exists naturally at the module level.compositionPhaseunion vs. boolean flags — The oldisFirefoxEndingComposition/isSafariEndingCompositionbooleans were mutually exclusive states of the same concept. A discriminated union makes the state machine explicit: exactly one phase is active at a time, and the valid transitions are readable from the type.Test plan
pnpm test-unit— 3682 passpnpm test-e2e-chromium— 768 pass