Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export interface CollapsedSelectionFormat {
export interface InputState {
compositionPhase: 'idle' | 'composing' | 'ending-firefox' | 'ending-safari';
compositionEndData: string;
compositionEndTimeStamp: number;
hadOrphanedCompositionEvents: boolean;

lastKeyDownTimeStamp: number;
Expand Down Expand Up @@ -291,6 +292,7 @@ export function createInputState(): InputState {
timeStamp: 0,
},
compositionEndData: '',
compositionEndTimeStamp: 0,
compositionPhase: 'idle',
hadOrphanedCompositionEvents: false,
handledSelectionCommandTimeoutId: null,
Expand Down
81 changes: 72 additions & 9 deletions packages/lexical/src/LexicalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
REDO_COMMAND,
REMOVE_TEXT_COMMAND,
SELECTION_CHANGE_COMMAND,
SKIP_SCROLL_INTO_VIEW_TAG,
SKIP_SELECTION_FOCUS_TAG,
UNDO_COMMAND,
} from '.';
Expand Down Expand Up @@ -169,6 +170,16 @@ type RootElementEvents = [
][];
const PASS_THROUGH_COMMAND = Object.freeze({});
const ANDROID_COMPOSITION_LATENCY = 30;
/**
* How soon after a keydown a composition event still counts as caused by it.
*
* Separate from ANDROID_COMPOSITION_LATENCY, which happens to hold the same
* number: that one is about how late Android's keyboard delivers its events, and
* this one is about whether a keystroke is behind the composition event at all.
* They answer different questions, so tuning one must not silently move the
* other.
*/
const RECENT_KEYDOWN_WINDOW = 30;
const rootElementEvents: RootElementEvents = [
['keydown', onKeyDown],
['pointerdown', onPointerDown],
Expand Down Expand Up @@ -1245,10 +1256,13 @@ function $handleInput(event: InputEvent): boolean {
// to ensure to disable composition before dispatching the
// insertText command for when changing the sequence for FF.
if (inputState.compositionPhase === 'ending-firefox') {
const tokenRedirected = $onCompositionEndImpl(editor, data);
const tokenRedirected = $onCompositionEndImpl(
editor,
data,
event.timeStamp,
);
inputState.compositionPhase = 'idle';
if (tokenRedirected) {
$addUpdateTag(COMPOSITION_END_TAG);
$flushMutations();
return true;
}
Expand Down Expand Up @@ -1313,8 +1327,7 @@ function $handleInput(event: InputEvent): boolean {
// trigger, history merge, autocomplete post-commit) see the same signal on
// Firefox.
if (inputState.compositionPhase === 'ending-firefox') {
$onCompositionEndImpl(editor, data || undefined);
$addUpdateTag(COMPOSITION_END_TAG);
$onCompositionEndImpl(editor, data || undefined, event.timeStamp);
inputState.compositionPhase = 'idle';
}
}
Expand Down Expand Up @@ -1351,7 +1364,7 @@ function $handleCompositionStart(event: CompositionEvent): boolean {
// apply the empty space heuristic. We can't do this for Safari,
// as the keydown fires after composition start.
event.timeStamp <
inputState.lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
inputState.lastKeyDownTimeStamp + RECENT_KEYDOWN_WINDOW ||
// FF has issues around composing multibyte characters, so we also
// need to invoke the empty space heuristic below.
anchor.type === 'element' ||
Expand Down Expand Up @@ -1387,8 +1400,7 @@ function $handleCompositionStart(event: CompositionEvent): boolean {
function $handleCompositionEnd(event: CompositionEvent): boolean {
const editor = getActiveEditor();
editor._inputState.compositionPhase = 'idle';
$onCompositionEndImpl(editor, event.data);
$addUpdateTag(COMPOSITION_END_TAG);
$onCompositionEndImpl(editor, event.data, event.timeStamp);
return true;
}

Expand Down Expand Up @@ -1420,10 +1432,52 @@ function $cleanupComposedSubclass(compositionKey: NodeKey | null): void {
}
}

function $onCompositionEndImpl(editor: LexicalEditor, data?: string): boolean {
/**
* Ends a composition, and tags the update it runs in.
*
* The tagging lives here rather than in each caller because every exit below is
* a composition that has ended, and the callers did not agree on that: the
* token-redirect path in $handleInput only tagged when it actually redirected,
* and Safari's deferred path did not tag at all. Owning it here makes the tag
* follow from ending the composition instead of from remembering to say so.
*
* `eventTimeStamp` is the `compositionend`'s, which on the deferred paths is not
* the event being handled — see InputState.compositionEndTimeStamp.
*/
function $onCompositionEndImpl(
editor: LexicalEditor,
data: string | undefined,
eventTimeStamp: number,
): boolean {
const compositionKey = editor._compositionKey;
$setCompositionKey(null);

$addUpdateTag(COMPOSITION_END_TAG);
// A compositionend that does not closely follow a keydown was not typed: the
// browser force-committed it, because a pointer press or a blur ended the
// composition for the user. It usually does so while the editor is still the
// active element, so the activeElement guards in $updateDOMSelection cannot
// tell — hence the timing. Reconciling such a commit must not grab focus, nor
// scroll a caret the user has deliberately scrolled away from, back into view.
//
// SKIP_SCROLL_INTO_VIEW_TAG only suppresses the scroll Lexical itself performs
// in reconciliation, which is what happens on Chromium. A browser that natively
// scrolls the caret into view on commit is beyond its reach — observed on
// Firefox, where a plain contentEditable jumps the same way, so the scroll is
// the browser's, not ours to suppress.
//
// The zero check keeps Android Chrome as it was: it zeroes lastKeyDownTimeStamp
// while composing (see $handleInput), so no keydown there can ever attest to a
// typed commit and every commit would otherwise look forced.
const {lastKeyDownTimeStamp} = editor._inputState;
if (
lastKeyDownTimeStamp !== 0 &&
eventTimeStamp >= lastKeyDownTimeStamp + RECENT_KEYDOWN_WINDOW
) {
$addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
$addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
}

// Handle termination of composition.
if (compositionKey !== null && data != null) {
// Composition can sometimes move to an adjacent DOM node when backspacing.
Expand Down Expand Up @@ -1518,6 +1572,9 @@ function onCompositionEnd(
// https://github.com/facebook/lexical/pull/7061
inputState.compositionPhase = 'ending-safari';
inputState.compositionEndData = event.data;
// Kept because the commit is processed on the next keydown, when this event
// is no longer around to be asked when the composition ended.
inputState.compositionEndTimeStamp = event.timeStamp;
} else {
dispatchCommand(editor, COMPOSITION_END_COMMAND, event);
}
Expand Down Expand Up @@ -1545,12 +1602,18 @@ function $handleKeyDown(event: KeyboardEvent): boolean {
if (inputState.compositionPhase === 'ending-safari') {
const isBack = isBackspace(event);
if (isBack) {
const compositionEndTimeStamp = inputState.compositionEndTimeStamp;
updateEditorSync(editor, () => {
$onCompositionEndImpl(editor, inputState.compositionEndData);
$onCompositionEndImpl(
editor,
inputState.compositionEndData,
compositionEndTimeStamp,
);
});
}
inputState.compositionPhase = 'idle';
inputState.compositionEndData = '';
inputState.compositionEndTimeStamp = 0;
if (isBack) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

/**
* Clicking anywhere else mid-composition — inside the editor or out of it —
* makes the browser force-commit and fire compositionend. The editor is still
* the active element at that point, so the activeElement guards in
* $updateDOMSelection pass and reconciliation used to scroll the caret back
* into view, undoing the scrolling the user just did to reach what they clicked.
*
* These run in a real browser (so the scroll is real) and go through the native
* compositionend path, which compose()'s default commit deliberately bypasses.
*/

import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
type LexicalEditor,
} from 'lexical';
import {expect, onTestFinished, test} from 'vitest';

import {compose, korean} from './utils/compose';

/** Enough paragraphs that the caret at the end sits well below the viewport. */
function createTallEditor(): LexicalEditor {
const editor = buildEditorFromExtensions({
$initialEditorState: () => {
const root = $getRoot();
for (let i = 0; i < 30; i++) {
root.append(
$createParagraphNode().append($createTextNode(`line ${i}`)),
);
}
},
dependencies: [RichTextExtension],
name: 'test',
});
const rootElement = document.createElement('div');
rootElement.contentEditable = 'true';
document.body.appendChild(rootElement);
editor.setRootElement(rootElement);
onTestFinished(() => {
editor.setRootElement(null);
document.body.removeChild(rootElement);
window.scrollTo(0, 0);
});
return editor;
}

/** Caret at the end, scrolled out of view. Records Lexical's scrolls from here. */
async function focusEndAndScrollAway(rootElement: HTMLElement) {
await new Promise(resolve => setTimeout(resolve, 0));
rootElement.focus();
const textSpans = rootElement.querySelectorAll('[data-lexical-text]');
const lastText = textSpans[textSpans.length - 1]?.firstChild;
expect(lastText).toBeInstanceOf(Text);
document
.getSelection()!
.collapse(lastText!, (lastText as Text).nodeValue!.length);

const scrolls: string[] = [];
const originalScrollBy = window.scrollBy.bind(window);
// scrollBy is overloaded, so the spy is cast rather than structurally typed.
window.scrollBy = ((...args: unknown[]) => {
scrolls.push(`scrollBy(${args.join(',')})`);
return (originalScrollBy as (...a: unknown[]) => void)(...args);
}) as typeof window.scrollBy;
onTestFinished(() => {
window.scrollBy = originalScrollBy;
});

window.scrollTo(0, 0);
await new Promise(resolve => setTimeout(resolve, 50));
expect(window.scrollY).toBe(0);
return scrolls;
}

// WebKit defers compositionend to the next keydown, which a click never sends,
// so it does not reach this path and passes trivially today. Left unskipped: if
// that deferral is ever fixed (it drops the commit outright — a separate bug),
// the path opens and this starts holding the line on its own.
test('a browser-forced commit does not scroll the caret back into view', async () => {
const editor = createTallEditor();
const rootElement = editor.getRootElement()!;
const scrolls = await focusEndAndScrollAway(rootElement);

await compose({editor, rootElement}, {...korean(['ㅁ']), commit: 'forced'});

expect(scrolls).toEqual([]);
expect(window.scrollY).toBe(0);
});

// The worse case, since the user never even left: the caret lands on the line
// they clicked but the scroll jumps to where they were composing and stays —
// the selection change that follows does not undo it.
test('a forced commit that moves the caret within the editor does not scroll', async () => {
const editor = createTallEditor();
const rootElement = editor.getRootElement()!;
const scrolls = await focusEndAndScrollAway(rootElement);

await compose({editor, rootElement}, {...korean(['ㅁ']), commit: 'forced'});

const visible = rootElement.querySelectorAll('[data-lexical-text]')[2]
.firstChild as Text;
document.getSelection()!.collapse(visible, 2);
document.dispatchEvent(new Event('selectionchange'));
await new Promise(resolve => setTimeout(resolve, 100));

expect(scrolls).toEqual([]);
expect(window.scrollY).toBe(0);
});

test('a typed commit still scrolls the caret back into view', async () => {
const editor = createTallEditor();
const rootElement = editor.getRootElement()!;
const scrolls = await focusEndAndScrollAway(rootElement);

// Default commit: the keystroke keydowns are what a typed commit looks like.
await compose({editor, rootElement}, korean(['ㅁ']));

expect(scrolls.length).toBeGreaterThan(0);
expect(window.scrollY).toBeGreaterThan(0);
});
50 changes: 49 additions & 1 deletion packages/lexical/src/__tests__/browser/utils/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@
* and defers to the next keydown; Firefox defers to the next input event),
* giving the test a single deterministic code path for all browsers.
*
* `commit: 'forced'` opts out of that bypass: it dispatches a real DOM
* compositionend and lets the native handler route it, deferral included —
* the only way to reach the code that tells a browser-forced commit apart
* from a typed one.
*
* Between each event, the browser yields to the microtask queue so
* deferred editor updates commit before the next event fires. We
* replicate this with `await flush()` after every event that triggers
* a Lexical `updateEditorSync` (compositionstart, input).
*/

import {COMPOSITION_END_COMMAND, type LexicalEditor} from 'lexical';
import {COMPOSITION_END_COMMAND, IS_FIREFOX, type LexicalEditor} from 'lexical';

/**
* Comfortably past the window in which Lexical still credits a keydown for a
* composition event (ANDROID_COMPOSITION_LATENCY, 30ms in LexicalEvents).
*/
const KEYDOWN_RECENCY_WINDOW_MS = 80;

export interface CompositionStep {
/** The cumulative composing text at this step. */
Expand All @@ -52,6 +63,22 @@ export interface CompositionSequence {
* committing. The DOM reverts to pre-composition text.
*/
cancel?: boolean;
/**
* How the composition ends.
*
* - `'command'` (default) dispatches COMPOSITION_END_COMMAND through the
* editor, bypassing the platform deferral for one path on every browser.
* Models a *typed* commit — the steps above fire keydowns, which is what a
* Space/Enter commit looks like to Lexical.
*
* - `'forced'` dispatches a real DOM `compositionend` with no keydown in
* front of it, as the browser does when it force-commits on focus leaving
* the editor. Routes through `onCompositionEnd`, so it exercises the
* platform branches `'command'` skips (Firefox's deferral onto the
* following `input`), and waits out the keydown-recency window first —
* the absence of a recent keydown is how Lexical spots a forced commit.
*/
commit?: 'command' | 'forced';
}

interface CompositionTarget {
Expand Down Expand Up @@ -129,6 +156,10 @@ function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}

function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Mutate the DOM text node to reflect the current composition state,
* replacing the composing region with the new text.
Expand Down Expand Up @@ -298,6 +329,23 @@ export async function compose(
}

const endData = cancel ? '' : commitText;

if (sequence.commit === 'forced') {
// The steps above fired keydowns; they have to fall out of the recency
// window before the commit reads as forced — as they do for real, since
// the user scrolls or clicks away between the last keystroke and the
// commit.
await sleep(KEYDOWN_RECENCY_WINDOW_MS);
dispatchCompositionEvent(rootElement, 'compositionend', endData);
if (IS_FIREFOX) {
// Firefox does not act on compositionend: it stashes the event and
// finishes the commit on the input that follows.
dispatchInputEvent(rootElement, endData, 'insertCompositionText', false);
}
await flush();
return;
}

target.editor.dispatchCommand(
COMPOSITION_END_COMMAND,
new CompositionEvent('compositionend', {
Expand Down
Loading
Loading