From d902767d3cfae69b4be3fbb76896b5cf422ec46b Mon Sep 17 00:00:00 2001
From: mayrang
Date: Sun, 31 May 2026 16:35:32 +0900
Subject: [PATCH 1/2] [lexical-playground][lexical-website] Feature:
Non-printing marks (#8592) (#8594)
Co-authored-by: Bob Ippolito
Co-authored-by: Claude
---
.../scripts/build-space-dot-font.py | 129 ++++++++++
packages/lexical-playground/src/App.tsx | 4 +-
packages/lexical-playground/src/Settings.tsx | 10 +-
.../lexical-playground/src/appSettings.ts | 2 +-
.../src/hooks/useSynchronizeSettings.ts | 6 +-
packages/lexical-playground/src/index.css | 3 +
.../src/plugins/VisibleLineBreakExtension.ts | 123 ---------
.../plugins/VisibleNonPrintingExtension.ts | 236 ++++++++++++++++++
.../src/themes/PlaygroundEditorTheme.css | 67 ++++-
.../docs/serialization/dom-render.md | 2 +-
10 files changed, 447 insertions(+), 135 deletions(-)
create mode 100644 packages/lexical-playground/scripts/build-space-dot-font.py
delete mode 100644 packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts
create mode 100644 packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts
diff --git a/packages/lexical-playground/scripts/build-space-dot-font.py b/packages/lexical-playground/scripts/build-space-dot-font.py
new file mode 100644
index 00000000000..fe954f256a2
--- /dev/null
+++ b/packages/lexical-playground/scripts/build-space-dot-font.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env -S uv run --script
+#
+# 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.
+
+# /// script
+# requires-python = ">=3.11"
+# dependencies = ["fonttools", "brotli"]
+# ///
+
+# Regenerates the inline WOFF2 used by `VisibleNonPrintingExtension` for the
+# space marker. The font has exactly one visible glyph: a round dot (a
+# middot) of diameter 120 units, centered horizontally in the `U+0020`
+# advance and sitting on the lowercase center line of a 1000 UPM em. Embedded
+# as a base64 data URI in `PlaygroundEditorTheme.css` (`@font-face` block,
+# see the comment above the inline `src`). Run this from the repo root with
+# `uv` (auto-installs `fonttools` and `brotli`):
+#
+# uv run packages/lexical-playground/scripts/build-space-dot-font.py
+#
+# The script prints the WOFF2 byte size and the base64 payload to paste into
+# the `@font-face` `src` URL.
+#
+# Note: WOFF2 byte size and payload may shift slightly across fontTools /
+# brotli versions for the same glyph input — commit the new base64 (and the
+# byte size in the CSS comment) when you regenerate.
+
+import base64
+import math
+import os
+import sys
+
+from fontTools.fontBuilder import FontBuilder
+from fontTools.pens.ttGlyphPen import TTGlyphPen
+from fontTools.ttLib import TTFont
+
+UPM = 1000
+ADVANCE = 260
+DOT_DIAMETER = 120
+# Center the dot in the advance so it sits in the middle of the rendered
+# space instead of hugging the left edge.
+DOT_CX = ADVANCE // 2
+DOT_CY = 350
+# Quadratic segments used to approximate the circle. 8 is visually round at
+# the sizes we render (sub-pixel deviation from a true circle) and still
+# compresses to a tiny WOFF2.
+DOT_SEGMENTS = 8
+
+
+def draw_dot(pen, cx, cy, radius, segments):
+ """Trace a circle as on-curve points joined by quadratic arcs.
+
+ TrueType outlines are quadratic, so each arc uses a single off-curve
+ control point pushed out to ``radius / cos(half-step)`` — the standard
+ quadratic-Bezier circle approximation.
+ """
+ step = 2.0 * math.pi / segments
+ control_radius = radius / math.cos(step / 2.0)
+ pen.moveTo((round(cx + radius), round(cy)))
+ for i in range(segments):
+ mid_angle = (i + 0.5) * step
+ end_angle = (i + 1) * step
+ control = (
+ round(cx + control_radius * math.cos(mid_angle)),
+ round(cy + control_radius * math.sin(mid_angle)),
+ )
+ end = (
+ round(cx + radius * math.cos(end_angle)),
+ round(cy + radius * math.sin(end_angle)),
+ )
+ pen.qCurveTo(control, end)
+ pen.closePath()
+
+
+def main() -> None:
+ fb = FontBuilder(UPM, isTTF=True)
+ fb.setupGlyphOrder([".notdef", "space"])
+ fb.setupCharacterMap({0x0020: "space"})
+
+ pen = TTGlyphPen(None)
+ draw_dot(pen, DOT_CX, DOT_CY, DOT_DIAMETER / 2.0, DOT_SEGMENTS)
+ space_glyph = pen.glyph()
+
+ notdef_glyph = TTGlyphPen(None).glyph()
+
+ fb.setupGlyf({".notdef": notdef_glyph, "space": space_glyph})
+
+ # The left side bearing must match the glyph's xMin. Browsers position the
+ # outline by its advertised lsb, so leaving it at 0 while the dot starts
+ # ~70 units in shoves the dot toward the left edge of the space instead of
+ # centering it. Derive it from the compiled bounds so it always tracks the
+ # geometry above.
+ glyf_table = fb.font["glyf"]
+ glyf_table["space"].recalcBounds(glyf_table)
+ space_lsb = glyf_table["space"].xMin
+ fb.setupHorizontalMetrics({".notdef": (ADVANCE, 0), "space": (ADVANCE, space_lsb)})
+ fb.setupHorizontalHeader(ascent=800, descent=-200)
+ fb.setupOS2(
+ sTypoAscender=800,
+ sTypoDescender=-200,
+ usWinAscent=800,
+ usWinDescent=200,
+ sCapHeight=700,
+ sxHeight=500,
+ )
+ fb.setupNameTable({"familyName": "LexicalSpaceDot", "styleName": "Regular"})
+ fb.setupPost()
+
+ ttf_path = "/tmp/LexicalSpaceDot.ttf"
+ woff2_path = "/tmp/LexicalSpaceDot.woff2"
+ fb.save(ttf_path)
+
+ font = TTFont(ttf_path)
+ font.flavor = "woff2"
+ font.save(woff2_path)
+
+ size = os.path.getsize(woff2_path)
+ with open(woff2_path, "rb") as f:
+ payload = base64.b64encode(f.read()).decode("ascii")
+
+ sys.stdout.write(f"WOFF2 size: {size} bytes\n")
+ sys.stdout.write("Base64 payload (paste into @font-face src):\n")
+ sys.stdout.write(payload + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx
index 1dee3512298..e1bcf336442 100644
--- a/packages/lexical-playground/src/App.tsx
+++ b/packages/lexical-playground/src/App.tsx
@@ -87,7 +87,7 @@ import {TerseExportExtension} from './plugins/TerseExportExtension';
import TestRecorderPlugin from './plugins/TestRecorderPlugin';
import {TwitterExtension} from './plugins/TwitterExtension';
import TypingPerfPlugin from './plugins/TypingPerfPlugin';
-import {VisibleLineBreakExtension} from './plugins/VisibleLineBreakExtension';
+import {VisibleNonPrintingExtension} from './plugins/VisibleNonPrintingExtension';
import {YouTubeExtension} from './plugins/YouTubeExtension';
import Settings from './Settings';
import PlaygroundEditorTheme from './themes/PlaygroundEditorTheme';
@@ -238,7 +238,7 @@ const AppExtension = defineExtension({
$defaultShouldInsertAfter(node) || $isCodeNode(node),
}),
configExtension(AutocompleteExtension, {disabled: true}),
- configExtension(VisibleLineBreakExtension, {disabled: true}),
+ configExtension(VisibleNonPrintingExtension, {disabled: true}),
// DOMImportExtension pipeline — `PlaygroundImportExtension` bundles
// the shared `CoreImportExtension` baseline, every per-package
// import extension (rich-text, list, link, table, code, hr), the
diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx
index 6bfb7be2714..95f7fa13146 100644
--- a/packages/lexical-playground/src/Settings.tsx
+++ b/packages/lexical-playground/src/Settings.tsx
@@ -29,7 +29,7 @@ export default function Settings(): JSX.Element {
isCharLimit,
isCharLimitUtf8,
isAutocomplete,
- isVisibleLineBreak,
+ isVisibleNonPrinting,
showTreeView,
showNestedEditorTreeView,
showTableOfContents,
@@ -148,9 +148,11 @@ export default function Settings(): JSX.Element {
text="Autocomplete"
/>
setOption('isVisibleLineBreak', !isVisibleLineBreak)}
- checked={isVisibleLineBreak}
- text="Visible Line Break"
+ onClick={() =>
+ setOption('isVisibleNonPrinting', !isVisibleNonPrinting)
+ }
+ checked={isVisibleNonPrinting}
+ text="Visible Non-Printing"
/>
{
diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts
index 12e4c4f0658..e3829e9d1ba 100644
--- a/packages/lexical-playground/src/appSettings.ts
+++ b/packages/lexical-playground/src/appSettings.ts
@@ -24,7 +24,7 @@ export const DEFAULT_SETTINGS = {
isCollab: false,
isMaxLength: false,
isRichText: true,
- isVisibleLineBreak: false,
+ isVisibleNonPrinting: false,
listStrictIndent: false,
measureTypingPerf: false,
selectionAlwaysOnDisplay: false,
diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts
index 715bc074fb3..dafe43715bd 100644
--- a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts
+++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts
@@ -32,7 +32,7 @@ import {AutocompleteExtension} from '../plugins/AutocompleteExtension';
import {CodeHighlightExtension} from '../plugins/CodeHighlightExtension';
import {MaxLengthExtension} from '../plugins/MaxLengthPlugin';
import {SpecialTextExtension} from '../plugins/SpecialTextExtension';
-import {VisibleLineBreakExtension} from '../plugins/VisibleLineBreakExtension';
+import {VisibleNonPrintingExtension} from '../plugins/VisibleNonPrintingExtension';
const DEFAULT_LINK_ATTRIBUTES: LinkAttributes = {
rel: 'noopener noreferrer',
@@ -86,8 +86,8 @@ export function synchronizeSettingsToSignals(
batch(() => {
output(editor, AutocompleteExtension).disabled.value =
!settings.isAutocomplete;
- output(editor, VisibleLineBreakExtension).disabled.value =
- !settings.isVisibleLineBreak;
+ output(editor, VisibleNonPrintingExtension).disabled.value =
+ !settings.isVisibleNonPrinting;
output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength;
const codeHighlight = peerOutput(editor, CodeHighlightExtension);
if (codeHighlight) {
diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css
index dbfc58a016d..33f57eafb01 100644
--- a/packages/lexical-playground/src/index.css
+++ b/packages/lexical-playground/src/index.css
@@ -20,6 +20,9 @@ body {
BlinkMacSystemFont,
'.SFNSText-Regular',
sans-serif;
+ --font-family:
+ system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular',
+ sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #eee;
diff --git a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts b/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts
deleted file mode 100644
index d622da9c4e0..00000000000
--- a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * 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.
- *
- */
-import {$isCodeNode} from '@lexical/code-core';
-import {effect, namedSignals} from '@lexical/extension';
-import {
- $setRenderContextValue,
- createRenderState,
- domOverride,
- DOMRenderExtension,
-} from '@lexical/html';
-import {
- configExtension,
- defineExtension,
- isHTMLElement,
- LineBreakNode,
- safeCast,
-} from 'lexical';
-
-/**
- * Worked example for the generalized `getDOMSlot` abstraction — wraps each
- * `LineBreakNode`'s `
` in a `` carrying a visible `↵` marker, and
- * exposes the inner `
` through `$getDOMSlot` so selection / caret logic
- * targets the canonical content element instead of the wrapper.
- *
- * Demonstrates the extension-driven path for a leaf node category: no
- * `LineBreakNode` subclass required, behaviour attaches via
- * `DOMRenderExtension` configuration.
- *
- * `disabled` toggles the wrap at runtime without recreating the editor. The
- * override is installed conditionally via `disabledForEditor`, so when disabled
- * it is removed from the render pipeline entirely rather than no-oping per
- * node. Flipping the signal mirrors it into the editor render context with
- * `$setRenderContextValue`, which recompiles the render config and recreates
- * the existing `LineBreakNode` DOM through the new config.
- */
-const VISIBLE_LINEBREAK_CLASS = 'visible-linebreak';
-const VISIBLE_LINEBREAK_ATTR = 'data-lexical-visible-linebreak';
-
-export interface VisibleLineBreakConfig {
- disabled: boolean;
-}
-
-/**
- * Editor render context state mirroring the extension's `disabled` signal.
- */
-export const VisibleLineBreakDisabled = createRenderState(
- 'visibleLineBreakDisabled',
- () => false,
-);
-
-function $skipForCodeChild(node: LineBreakNode): boolean {
- // Code blocks convey line structure visually — skip the visible
- // linebreak wrap anywhere inside a `CodeNode`.
- for (
- let ancestor = node.getParent();
- ancestor !== null;
- ancestor = ancestor.getParent()
- ) {
- if ($isCodeNode(ancestor)) {
- return true;
- }
- }
- return false;
-}
-
-function hasOurWrap(dom: HTMLElement): boolean {
- return dom.tagName === 'SPAN' && dom.hasAttribute(VISIBLE_LINEBREAK_ATTR);
-}
-
-export const VisibleLineBreakExtension = defineExtension({
- build: (editor, config) => namedSignals(config),
- config: safeCast({disabled: false}),
- dependencies: [
- configExtension(DOMRenderExtension, {
- overrides: [
- domOverride(
- [LineBreakNode],
- {
- $createDOM: (node, $next) => {
- const inner = $next();
- if ($skipForCodeChild(node)) {
- return inner;
- }
- const wrapper = document.createElement('span');
- wrapper.className = VISIBLE_LINEBREAK_CLASS;
- wrapper.setAttribute(VISIBLE_LINEBREAK_ATTR, 'true');
- wrapper.appendChild(inner);
- return wrapper;
- },
- $getDOMSlot: (_node, dom, $next) => {
- const br = dom.querySelector(':scope > br');
- return isHTMLElement(br) ? $next().withElement(br) : $next();
- },
- $updateDOM: (node, _prev, dom, $next) => {
- const wantsWrap = !$skipForCodeChild(node);
- if (wantsWrap !== hasOurWrap(dom)) {
- return true;
- }
- return $next();
- },
- },
- {disabledForEditor: ctx => ctx.get(VisibleLineBreakDisabled)},
- ),
- ],
- }),
- ],
- name: '@lexical/playground/visible-linebreak',
- register: (editor, _config, state) => {
- const stores = state.getOutput();
- return effect(() => {
- $setRenderContextValue(
- VisibleLineBreakDisabled,
- stores.disabled.value,
- editor,
- );
- });
- },
-});
diff --git a/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts b/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts
new file mode 100644
index 00000000000..d2a970a1266
--- /dev/null
+++ b/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts
@@ -0,0 +1,236 @@
+/**
+ * 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.
+ *
+ */
+import type {DOMOverrideOptions} from '@lexical/html';
+
+import {$isCodeNode} from '@lexical/code-core';
+import {effect, namedSignals} from '@lexical/extension';
+import {
+ $setRenderContextValue,
+ createRenderState,
+ domOverride,
+ DOMRenderExtension,
+} from '@lexical/html';
+import {ListItemNode} from '@lexical/list';
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {$canShowPlaceholder} from '@lexical/text';
+import {$findMatchingParent, mergeRegister} from '@lexical/utils';
+import {
+ $isTabNode,
+ configExtension,
+ defineExtension,
+ ElementNode,
+ getStyleObjectFromCSS,
+ isHTMLElement,
+ LineBreakNode,
+ ParagraphNode,
+ safeCast,
+ TabNode,
+ TextNode,
+} from 'lexical';
+
+/**
+ * Surfaces a visible marker for each non-printing character in the editor.
+ * Currently covers:
+ * - `LineBreakNode` (`↵`) — wraps each `
` in a `` carrying the
+ * marker, and exposes the inner `
` through `$getDOMSlot` so selection /
+ * caret logic targets the canonical content element.
+ * - `ParagraphNode` / `HeadingNode` / `ListItemNode` / `QuoteNode` (`¶`) —
+ * shared `data-lexical-visible-non-printing-block` attribute on the block's
+ * DOM, visual rendered via CSS `::after`. The marker is `position: absolute`
+ * and `:has(> br:last-child)` snaps it to `bottom: 0` so a trailing
+ * placeholder `
` (added by `$reconcileElementTerminatingLineBreak` when
+ * Shift+Enter ends the block) shares the empty line instead of bumping the
+ * marker onto a new one. The direct-child selector keeps a wrapped
+ * `LineBreakNode`'s inner `
` from accidentally matching.
+ * - `TabNode` (`→`) — `data-` attribute marker rendered via CSS `::before`,
+ * centered with `position: absolute; transform: translate(-50%, -50%)` so the
+ * arrow sits in the middle of the tab whitespace rather than at the left
+ * edge. Indent applied via `INDENT_CONTENT_COMMAND` (Tab on a block-start
+ * caret) does not create a `TabNode` and is therefore not marked, matching
+ * Word's "Show formatting marks" behaviour where indent and literal tabs are
+ * distinct.
+ * - Space (` `, U+0020) (`·`) — an inline WOFF2 with `unicode-range: U+0020`
+ * remaps only the space glyph to a middle-dot shape, gated by the editor
+ * root's `data-lexical-visible-non-printing-active` attribute. A
+ * `TextNode` `$createDOM` / `$updateDOM` override prepends our font in
+ * front of any inline `style="font-family"` so the toolbar font-family
+ * selector can't strip the marker via specificity. Zero text-content
+ * mutation, so IME composition, selection, and caret behaviour stay
+ * intact.
+ *
+ * Demonstrates the extension-driven path for leaf and block node categories:
+ * no subclassing required, behaviour attaches via `DOMRenderExtension`
+ * configuration.
+ *
+ * `disabled` toggles the markers at runtime without recreating the editor.
+ * The overrides are installed conditionally via `disabledForEditor`, so when
+ * disabled they are removed from the render pipeline entirely rather than
+ * no-oping per node. Flipping the signal mirrors it into the editor render
+ * context with `$setRenderContextValue`, which recompiles the render config
+ * and recreates the existing DOM through the new config. The empty-root
+ * listener is registered inside the `effect` callback so it tears down when
+ * the extension is disabled instead of running forever.
+ */
+const VISIBLE_NON_PRINTING_LINEBREAK_ATTR =
+ 'data-lexical-visible-non-printing-linebreak';
+const VISIBLE_NON_PRINTING_BLOCK_ATTR =
+ 'data-lexical-visible-non-printing-block';
+const VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR =
+ 'data-lexical-visible-non-printing-empty-root';
+const VISIBLE_NON_PRINTING_ACTIVE_ATTR =
+ 'data-lexical-visible-non-printing-active';
+const VISIBLE_NON_PRINTING_TAB_ATTR = 'data-lexical-visible-non-printing-tab';
+
+export interface VisibleNonPrintingConfig {
+ disabled: boolean;
+}
+
+/**
+ * Editor render context state mirroring the extension's `disabled` signal.
+ */
+export const VisibleNonPrintingDisabled = createRenderState(
+ 'visibleNonPrintingDisabled',
+ () => false,
+);
+
+function $skipForCodeChild(node: LineBreakNode): boolean {
+ // Code blocks convey line structure visually — skip the visible
+ // linebreak wrap anywhere inside a `CodeNode`.
+ return $findMatchingParent(node, $isCodeNode) !== null;
+}
+
+function hasOurWrap(dom: HTMLElement): boolean {
+ return (
+ dom.tagName === 'SPAN' &&
+ dom.hasAttribute(VISIBLE_NON_PRINTING_LINEBREAK_ATTR)
+ );
+}
+
+const LEXICAL_SPACE_DOT_FONT = "'LexicalSpaceDot'";
+
+const disabledForEditor = {
+ disabledForEditor: ctx => ctx.get(VisibleNonPrintingDisabled),
+} satisfies DOMOverrideOptions;
+
+export const VisibleNonPrintingExtension = defineExtension({
+ build: (editor, config) => namedSignals(config),
+ config: safeCast({disabled: false}),
+ dependencies: [
+ configExtension(DOMRenderExtension, {
+ overrides: [
+ domOverride(
+ [LineBreakNode],
+ {
+ $createDOM: (node, $next) => {
+ const inner = $next();
+ if ($skipForCodeChild(node)) {
+ return inner;
+ }
+ const wrapper = document.createElement('span');
+ wrapper.setAttribute(VISIBLE_NON_PRINTING_LINEBREAK_ATTR, 'true');
+ wrapper.appendChild(inner);
+ return wrapper;
+ },
+ $getDOMSlot: (_node, dom, $next) => {
+ const br = dom.querySelector(':scope > br');
+ return isHTMLElement(br) ? $next().withElement(br) : $next();
+ },
+ $updateDOM: (node, _prev, dom, $next) => {
+ const wantsWrap = !$skipForCodeChild(node);
+ if (wantsWrap !== hasOurWrap(dom)) {
+ return true;
+ }
+ return $next();
+ },
+ },
+ disabledForEditor,
+ ),
+ domOverride(
+ [ParagraphNode, HeadingNode, ListItemNode, QuoteNode],
+ {
+ $decorateDOM: (node, _prevNode, dom) => {
+ dom.setAttribute(VISIBLE_NON_PRINTING_BLOCK_ATTR, 'true');
+ const nextTextStyle = getStyleObjectFromCSS(node.__textStyle);
+ for (const prop of ['font-size', 'font-weight', 'font-family']) {
+ dom.style.setProperty(
+ `--text-${prop}`,
+ nextTextStyle[prop] || null,
+ );
+ }
+ },
+ },
+ disabledForEditor,
+ ),
+ domOverride(
+ [TextNode, TabNode],
+ {
+ $decorateDOM: (node, _prev, dom) => {
+ // TabNode descends from TextNode so we are going to hit this case either way
+ if ($isTabNode(node)) {
+ dom.setAttribute(VISIBLE_NON_PRINTING_TAB_ATTR, 'true');
+ return;
+ }
+ // Prepend our space-dot font in front of any inline `font-family` set on a
+ // TextNode's span (e.g. by the playground's font-family toolbar). The root
+ // rule's `font-family` stack already covers spans without an inline style;
+ // the inline one wins specificity, so we have to apply the override at the
+ // node level. `unicode-range: U+0020` still scopes the substitution to the
+ // space glyph alone, so the user's chosen face renders every other glyph.
+ const inline =
+ dom.style.fontFamily ||
+ getStyleObjectFromCSS(node.__style)['font-family'] ||
+ 'var(--font-family)';
+ if (inline.startsWith(LEXICAL_SPACE_DOT_FONT)) {
+ return;
+ }
+ dom.style.fontFamily = `${LEXICAL_SPACE_DOT_FONT}, ${inline}`;
+ },
+ },
+ disabledForEditor,
+ ),
+ ],
+ }),
+ ],
+ name: '@lexical/playground/visible-non-printing',
+ register: (editor, _config, state) => {
+ const stores = state.getOutput();
+ return effect(() => {
+ const isDisabled = stores.disabled.value;
+ $setRenderContextValue(VisibleNonPrintingDisabled, isDisabled, editor);
+ if (isDisabled) {
+ return;
+ }
+ return editor.registerRootListener(nextRoot => {
+ if (nextRoot === null) {
+ return;
+ }
+ nextRoot.setAttribute(VISIBLE_NON_PRINTING_ACTIVE_ATTR, 'true');
+ const syncEmptyRootAttr = () => {
+ const showPlaceholder = editor
+ .getEditorState()
+ .read(() => $canShowPlaceholder(editor.isComposing()));
+ nextRoot.toggleAttribute(
+ VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR,
+ showPlaceholder,
+ );
+ };
+ syncEmptyRootAttr();
+ // mergeRegister tears down in LIFO order: the attribute removal runs
+ // before the update listener unregister, so `syncEmptyRootAttr` can't
+ // refire on a half-cleaned root between the two cleanups.
+ return mergeRegister(
+ editor.registerUpdateListener(syncEmptyRootAttr),
+ () => {
+ nextRoot.removeAttribute(VISIBLE_NON_PRINTING_ACTIVE_ATTR);
+ nextRoot.removeAttribute(VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR);
+ },
+ );
+ });
+ });
+ },
+});
diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
index ab779f1d39b..e25ad977462 100644
--- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
+++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
@@ -118,6 +118,7 @@
.PlaygroundEditorTheme__textCode {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
+ --font-family: Menlo, Consolas, Monaco, monospace;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
@@ -164,6 +165,7 @@
.PlaygroundEditorTheme__code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
+ --font-family: Menlo, Consolas, Monaco, monospace;
display: block;
line-height: 1.53;
font-size: 13px;
@@ -740,10 +742,73 @@
min-height: var(--page-height);
}
}
-[data-lexical-visible-linebreak]::before {
+[data-lexical-visible-non-printing-linebreak]::before {
content: '↵';
color: #999;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
+ position: absolute;
+}
+[data-lexical-visible-non-printing-block] {
+ position: relative;
+}
+[data-lexical-visible-non-printing-block]::after {
+ content: '¶';
+ color: #999;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-select: none;
+ font-size: var(--text-font-size, inherit);
+ font-weight: var(--text-font-weight, inherit);
+ font-family: var(--text-font-family, inherit);
+ text-decoration: none;
+ position: absolute;
+}
+[data-lexical-visible-non-printing-block]:has(br:only-child) {
+ font-size: var(--text-font-size, inherit);
+ font-weight: var(--text-font-weight, inherit);
+ font-family: var(--text-font-family, inherit);
+}
+[data-lexical-visible-non-printing-block]:has(br:last-child)::after {
+ bottom: 0;
+}
+[data-lexical-visible-non-printing-empty-root]
+ [data-lexical-visible-non-printing-block]::after {
+ content: '\200B';
+}
+[data-lexical-visible-non-printing-tab] {
+ position: relative;
+}
+[data-lexical-visible-non-printing-tab]::before {
+ content: '';
+ position: absolute;
+ left: 8%;
+ right: 8%;
+ top: 50%;
+ height: 8px;
+ transform: translateY(-50%);
+ /* Two-layer composition: fixed-size SVG arrowhead pinned to the right end,
+ plus a 1px stem that stretches across the rest of the tab whitespace.
+ Decouples the arrowhead shape from tab width so heading fonts (large tab
+ widths) and body fonts (small tab widths) both render a sane arrow. */
+ background:
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 8'%3E%3Cpath d='M0 0 L6 4 L0 8' fill='none' stroke='%23999' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
+ no-repeat right center / 6px 8px,
+ linear-gradient(#999, #999) no-repeat left center / calc(100% - 6px) 1px;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+/* Inline WOFF2 (~308 bytes) whose only glyph is a 120×120 unit square dot
+ centered at (130, 350) in a 1000 UPM space slot — maps U+0020 to a middle
+ dot for the VisibleNonPrintingExtension. Regenerate via
+ packages/lexical-playground/scripts/build-space-dot-font.py (requires
+ fonttools + brotli). */
+@font-face {
+ font-family: 'LexicalSpaceDot';
+ src: url('data:font/woff2;base64,d09GMgABAAAAAAFMAAoAAAAAAqQAAAEFAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAANAo4TwE2AiQDBgsGAAQgBXgHJhsAAiAvCmwbtjU1FiYhthYrpIeZP0WUDdns/1FKf0mxBFuaQjicQUicrhqlCPGg0W2W9y1dXXVcQyQEwQiD/qtPR6qarErVtE5oiiLwZ2DZQ/6H47jBcUQBQcKB0aD7Io/jhLuN+fQIbnfU7qKeyosJRQxw2QbD6OM/+vAFkF96xwANHeu6YF0ZKCUY/zzJAqCBQrCMVUABsvzk9M3G8Xrmm8uz/2E7y9nZyfIO60AQXnvE07/p3n/g+9o3cg8hn1joGggzLUp50HwIACApPKholgU0AACUCwGxhIBmypIuNSMObVbYLDp//JnAb9cc+UrqG6VTjxHgpg2EYiENarkcHCTaCQA=')
+ format('woff2');
+ unicode-range: U+0020;
+ font-display: block;
}
diff --git a/packages/lexical-website/docs/serialization/dom-render.md b/packages/lexical-website/docs/serialization/dom-render.md
index 3f2679f5809..a138a0457a7 100644
--- a/packages/lexical-website/docs/serialization/dom-render.md
+++ b/packages/lexical-website/docs/serialization/dom-render.md
@@ -450,7 +450,7 @@ configExtension(DOMRenderExtension, {
{
$createDOM(node, $next) {
const wrapper = document.createElement('span');
- wrapper.className = 'visible-linebreak';
+ wrapper.className = 'visible-non-printing-linebreak';
wrapper.appendChild($next());
return wrapper;
},
From ed31918dd05a14930e124592e40cfc213acfbcdd Mon Sep 17 00:00:00 2001
From: Bob Ippolito
Date: Sun, 31 May 2026 09:36:35 -0700
Subject: [PATCH 2/2] [lexical-playground] Chore: De-flake e2e timing- and
history-dependent tests (#8595)
Co-authored-by: Claude
---
.github/workflows/call-e2e-all-tests.yml | 29 ++---
.github/workflows/call-e2e-test.yml | 12 +-
package.json | 16 +--
.../__tests__/e2e/ClearFormatting.spec.mjs | 13 +-
.../__tests__/e2e/CodeActionMenu.spec.mjs | 12 +-
.../__tests__/e2e/Collaboration.spec.mjs | 117 ++++++++++--------
.../__tests__/e2e/Composition.spec.mjs | 9 +-
.../__tests__/e2e/File.spec.mjs | 5 +-
.../__tests__/e2e/History.spec.mjs | 16 +--
.../__tests__/e2e/Mentions.spec.mjs | 61 ++-------
.../__tests__/e2e/Selection.spec.mjs | 6 +-
.../__tests__/e2e/Tables.spec.mjs | 28 +++--
.../379-backspace-with-mentions.spec.mjs | 7 +-
.../__tests__/utils/index.mjs | 88 +++++++++++++
.../src/shared/useYjsCollaboration.tsx | 24 ++++
15 files changed, 265 insertions(+), 178 deletions(-)
diff --git a/.github/workflows/call-e2e-all-tests.yml b/.github/workflows/call-e2e-all-tests.yml
index 4260ffea4bb..d1e734362dc 100644
--- a/.github/workflows/call-e2e-all-tests.yml
+++ b/.github/workflows/call-e2e-all-tests.yml
@@ -154,6 +154,19 @@ jobs:
browser: ${{ matrix.browser }}
editor-mode: 'rich-text-with-collab'
+ collab-v2-linux:
+ needs: [build-ubuntu]
+ strategy:
+ matrix:
+ node-version: [24.x]
+ browser: ['chromium', 'firefox']
+ uses: ./.github/workflows/call-e2e-test.yml
+ with:
+ os: 'ubuntu-latest'
+ node-version: ${{ matrix.node-version }}
+ browser: ${{ matrix.browser }}
+ editor-mode: 'rich-text-with-collab-v2'
+
collab-windows:
needs: [build-windows]
strategy:
@@ -198,19 +211,3 @@ jobs:
node-version: ${{ matrix.node-version }}
browser: ${{ matrix.browser }}
editor-mode: ${{ matrix.editor-mode }}
- flaky:
- # There aren't currently any flaky tests
- if: false
- needs: [build-ubuntu]
- strategy:
- matrix:
- node-version: [24.x]
- browser: ['chromium', 'firefox']
- editor-mode: ['rich-text', 'plain-text', 'rich-text-with-collab']
- uses: ./.github/workflows/call-e2e-test.yml
- with:
- os: 'ubuntu-latest'
- flaky: true
- node-version: ${{ matrix.node-version }}
- browser: ${{ matrix.browser }}
- editor-mode: ${{ matrix.editor-mode }}
diff --git a/.github/workflows/call-e2e-test.yml b/.github/workflows/call-e2e-test.yml
index 156f4ba382d..7ce9d8d4105 100644
--- a/.github/workflows/call-e2e-test.yml
+++ b/.github/workflows/call-e2e-test.yml
@@ -9,7 +9,6 @@ on:
browser: {required: true, type: string}
editor-mode: {required: true, type: string}
prod: {required: false, type: boolean}
- flaky: {required: false, type: boolean} # Defaults to false: dont run flaky
fail-on-cache-miss: {required: false, type: boolean, default: true}
permissions: {}
@@ -17,7 +16,6 @@ permissions: {}
jobs:
e2e-test:
runs-on: ${{ inputs.os }}
- continue-on-error: ${{ inputs.flaky }}
if: (inputs.browser != 'webkit' || inputs.os == 'macos-latest')
env:
CI: true
@@ -39,19 +37,19 @@ jobs:
node-version: ${{ inputs.node-version }}
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
- name: Run tests
- if: inputs.editor-mode != 'rich-text-with-collab'
- run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} "@flaky"${{ inputs.flaky && ' --pass-with-no-tests' || '' }}
+ if: ${{ !startsWith(inputs.editor-mode, 'rich-text-with-collab') }}
+ run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }}
- name: Run collab tests
- if: inputs.editor-mode == 'rich-text-with-collab'
+ if: ${{ startsWith(inputs.editor-mode, 'rich-text-with-collab') }}
shell: bash
run: |
pnpm exec concurrently -k -s first \
"pnpm run collab" \
- "pnpm run test-e2e-collab-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} @flaky${{ inputs.flaky && ' --pass-with-no-tests' || '' }}"
+ "pnpm run test-e2e-collab-${{ inputs.editor-mode == 'rich-text-with-collab-v2' && 'v2-' || '' }}${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }}"
- name: Upload Artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
- name: Test Results ${{ inputs.os }}-${{ inputs.browser }}-${{ inputs.editor-mode }}-${{ inputs.prod && 'prod' || 'dev' }}-${{ inputs.node-version }}-${{ inputs.flaky && 'flaky' || ''}}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
+ name: Test Results ${{ inputs.os }}-${{ inputs.browser }}-${{ inputs.editor-mode }}-${{ inputs.prod && 'prod' || 'dev' }}-${{ inputs.node-version }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.test_results_path }}
retention-days: 7
diff --git a/package.json b/package.json
index 73d43c125a6..366eb64f66c 100644
--- a/package.json
+++ b/package.json
@@ -62,14 +62,14 @@
"test-e2e-collab-v2-webkit": "cross-env E2E_BROWSER=webkit E2E_EDITOR_MODE=rich-text-with-collab-v2 playwright test --project=\"webkit\"",
"test-e2e-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 playwright test --project=\"chromium\"",
"test-e2e-collab-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"chromium\"",
- "test-e2e-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-chromium --grep-invert \"@flaky\"",
- "test-e2e-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-firefox --grep-invert \"@flaky\"",
- "test-e2e-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-webkit --grep-invert \"@flaky\"",
- "test-e2e-collab-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-chromium --grep-invert '@flaky' {@}\"",
- "test-e2e-collab-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-firefox --grep-invert '@flaky' {@}\"",
- "test-e2e-collab-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-webkit --grep-invert '@flaky' {@}\"",
- "test-e2e-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 pnpm run test-e2e-prod-chromium --grep-invert \"@flaky\"",
- "test-e2e-collab-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-prod-chromium --grep-invert '@flaky' {@}\"",
+ "test-e2e-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-chromium",
+ "test-e2e-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-firefox",
+ "test-e2e-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-webkit",
+ "test-e2e-collab-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-chromium {@}\"",
+ "test-e2e-collab-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-firefox {@}\"",
+ "test-e2e-collab-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-webkit {@}\"",
+ "test-e2e-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 pnpm run test-e2e-prod-chromium",
+ "test-e2e-collab-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-prod-chromium {@}\"",
"debug-run-all": "npm-run-all 'debug-test-e2e-* -- {1}' --",
"run-all": "npm-run-all 'test-e2e-* -- {1}' --",
"debug-test-e2e": "cross-env playwright test --debug",
diff --git a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs
index 89815224874..8023c9c47c6 100644
--- a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs
@@ -28,7 +28,7 @@ import {
selectFromBackgroundColorPicker,
selectFromColorPicker,
test,
- waitForSelector,
+ waitForTypeaheadMenuOption,
} from '../utils/index.mjs';
test.describe('Clear All Formatting', () => {
@@ -131,14 +131,9 @@ test.describe('Clear All Formatting', () => {
await page.keyboard.type('@Luke');
- // Wait until "Luke Skywalker" is the *highlighted* option, not merely
- // present: while "@Luke" is still being typed, the partial query "@Lu"
- // also matches "Agent Kallus" (kal**lu**s), which sorts earlier in the
- // list and is highlighted first, so pressing Enter too early selects it.
- await waitForSelector(
- page,
- '#typeahead-menu ul li[aria-selected="true"]:has-text("Luke Skywalker")',
- );
+ // Wait until "Luke Skywalker" is the *highlighted* option before pressing
+ // Enter; see waitForTypeaheadMenuOption for why merely-present is racy.
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
html`
diff --git a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs
index 19f9480854f..c7563abdf8f 100644
--- a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs
@@ -208,7 +208,13 @@ test.describe('CodeActionMenu', () => {
await mouseMoveToSelector(page, 'code.PlaygroundEditorTheme__code');
await click(page, 'button[aria-label=prettier]');
- await page.waitForTimeout(3000);
+ // Prettier loads and formats asynchronously; wait for the reformatted
+ // result instead of a fixed timeout. Formatting turns the single input
+ // line into multiple lines, so a
inside the code block is a reliable
+ // "prettier finished" signal (attached, not visible:
has no box).
+ await waitForSelector(page, 'code.PlaygroundEditorTheme__code br', {
+ state: 'attached',
+ });
await assertHTML(
page,
@@ -254,7 +260,9 @@ test.describe('CodeActionMenu', () => {
await mouseMoveToSelector(page, 'code.PlaygroundEditorTheme__code');
await click(page, 'button[aria-label=prettier]');
- await page.waitForTimeout(3000);
+ // Prettier reports invalid syntax asynchronously; wait for the error badge
+ // to appear instead of a fixed timeout (the assertions below do not retry).
+ await waitForSelector(page, 'i.format.prettier-error');
expect(await page.$('i.format.prettier-error')).toBeTruthy();
diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
index 8fb5c12ed7c..ee7c4e0bed2 100644
--- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
@@ -10,18 +10,20 @@ import {expect} from '@playwright/test';
import {
moveLeft,
+ moveToLineBeginning,
+ moveToLineEnd,
selectCharacters,
toggleBold,
undo,
} from '../keyboardShortcuts/index.mjs';
import {
+ advanceHistoryClock,
assertHTML,
assertSelection,
click,
focusEditor,
html,
initialize,
- sleep,
test,
} from '../utils/index.mjs';
@@ -46,7 +48,7 @@ test.describe('Collaboration', () => {
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.keyboard.type('world');
- await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency.
+ await advanceHistoryClock(page);
await page.keyboard.press('ArrowUp');
await page.keyboard.type('hello world again');
@@ -229,17 +231,20 @@ test.describe('Collaboration', () => {
await focusEditor(page);
await page.keyboard.type('Line 1');
await page.keyboard.press('Enter');
- await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency.
+ await advanceHistoryClock(page);
await page.keyboard.type('This is a test. ');
- // Right collaborator types at the end of paragraph 2
- await sleep(1050);
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph 2
- await page.keyboard.press('ArrowDown');
+ // Right collaborator types at the end of paragraph 2. Click into that
+ // paragraph to place the caret — a real remote user positions the cursor
+ // with a click — then move to the line end. Relying on a default caret
+ // position + ArrowDown is fragile: once content has synced in, the idle
+ // frame's selection is not guaranteed to start at the top of the document.
+ const rightFrame = page.frameLocator('iframe[name="right"]');
+ await expect(
+ rightFrame.locator('[data-lexical-editor="true"]'),
+ ).toContainText('This is a test.');
+ await rightFrame.locator('p').nth(1).click();
+ await moveToLineEnd(page);
await page.keyboard.type('Word');
await assertHTML(
@@ -311,7 +316,7 @@ test.describe('Collaboration', () => {
`,
);
- await sleep(1050);
+ await advanceHistoryClock(page);
await toggleBold(page);
await page.keyboard.type('bold');
@@ -329,12 +334,11 @@ test.describe('Collaboration', () => {
`,
);
- // Right collaborator types at the end of the paragraph.
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- await page.keyboard.press('ArrowDown', {delay: 50}); // Move caret to end of paragraph
+ // Right collaborator types at the end of the paragraph. Click to place the
+ // caret (real remote user positioning) then move to the line end, rather
+ // than relying on a default caret position + ArrowDown.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineEnd(page);
await page.keyboard.type('BOLD');
await assertHTML(
@@ -351,9 +355,14 @@ test.describe('Collaboration', () => {
`,
);
- // Left collaborator undoes their bold text.
- await sleep(1050);
- await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click();
+ // Left collaborator undoes their bold text. The assertHTML above already
+ // confirmed both frames converged, so wait for the undo control to be
+ // actionable instead of sleeping.
+ const undoButton = page
+ .frameLocator('iframe[name="left"]')
+ .getByLabel('Undo');
+ await expect(undoButton).toBeEnabled();
+ await undoButton.click();
// The undo also removed bold the text node from YJS.
// Check that the dangling text from right user was merged into the preceding text node.
@@ -396,7 +405,7 @@ test.describe('Collaboration', () => {
await focusEditor(page);
await page.keyboard.type('normal bold');
- await sleep(1050);
+ await advanceHistoryClock(page);
await selectCharacters(page, 'left', 'bold'.length);
await toggleBold(page);
@@ -414,12 +423,12 @@ test.describe('Collaboration', () => {
`,
);
- // Right collaborator types in the middle of the bold word.
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- await page.keyboard.press('ArrowDown', {delay: 50});
+ // Right collaborator types in the middle of the bold word. Click to place
+ // the caret (real remote-user positioning), then step left from the line
+ // end — relying on a default caret position + ArrowDown is fragile because
+ // the idle frame's synced selection isn't guaranteed to start at the top.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineEnd(page);
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.keyboard.type('BOLD');
@@ -481,17 +490,16 @@ test.describe('Collaboration', () => {
`,
);
- // Collaboration should still work.
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- // TODO this is a workaround for Firefox so that the
- // selection picks up the text format
+ // Collaboration should still work. Click into the paragraph and move to the
+ // line end, rather than relying on a default caret position + ArrowDown.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineEnd(page);
+ // Firefox doesn't carry the bold format to a caret at the very end of the
+ // bold run, so re-enter the run and return so the appended text inherits it.
if (browserName === 'firefox') {
- await page.keyboard.press('ArrowLeft', {delay: 50});
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('ArrowRight');
}
- await page.keyboard.press('ArrowDown', {delay: 50});
await page.keyboard.type(' text');
await assertHTML(
@@ -521,7 +529,7 @@ test.describe('Collaboration', () => {
await focusEditor(page);
await page.keyboard.type('Check out the website!');
- await sleep(1050);
+ await advanceHistoryClock(page);
await page.keyboard.press('ArrowLeft');
await selectCharacters(page, 'left', 'website'.length);
await page
@@ -546,12 +554,11 @@ test.describe('Collaboration', () => {
`,
);
- // Right collaborator adds some text.
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- await page.keyboard.press('ArrowDown', {delay: 50});
+ // Right collaborator adds some text just before the trailing "!". Click to
+ // place the caret (real remote user positioning), move to the line end,
+ // then step left over "!", rather than relying on a default caret position.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineEnd(page);
await page.keyboard.press('ArrowLeft');
await page.keyboard.type(' now');
@@ -616,12 +623,11 @@ test.describe('Collaboration', () => {
`,
);
- // Collaboration should still work.
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
- await page.keyboard.press('ArrowDown', {delay: 50});
+ // Collaboration should still work. Click into the paragraph and move to the
+ // line end to append after "now!", rather than relying on a default caret
+ // position + ArrowDown.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineEnd(page);
await page.keyboard.type(' Check it out.');
await assertHTML(
@@ -674,7 +680,7 @@ test.describe('Collaboration', () => {
);
// Left collaborator deletes A, right deletes B.
- await sleep(1050);
+ await advanceHistoryClock(page);
await page.keyboard.press('Delete');
await assertHTML(
page,
@@ -689,10 +695,11 @@ test.describe('Collaboration', () => {
`,
);
- await page
- .frameLocator('iframe[name="right"]')
- .locator('[data-lexical-editor="true"]')
- .focus();
+ // Right collaborator deletes "B". Click into the paragraph and move to the
+ // line start so forward-Delete removes the leading bold "B", rather than
+ // relying on a default caret position.
+ await page.frameLocator('iframe[name="right"]').locator('p').click();
+ await moveToLineBeginning(page);
await page.keyboard.press('Delete');
await assertHTML(
diff --git a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs
index 5c44a37237d..b326c12db06 100644
--- a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs
@@ -27,6 +27,7 @@ import {
keyUpCtrlOrMeta,
test,
waitForSelector,
+ waitForTypeaheadMenuOption,
} from '../utils/index.mjs';
test.use({launchOptions: {slowMo: 50}});
@@ -644,7 +645,7 @@ test.describe('Composition', () => {
await enableCompositionKeyEvents(page);
await page.keyboard.type('@Luke');
- await waitForSelector(page, '#typeahead-menu ul li');
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention');
@@ -754,7 +755,7 @@ test.describe('Composition', () => {
await enableCompositionKeyEvents(page);
await page.keyboard.type('@Luke');
- await waitForSelector(page, '#typeahead-menu ul li');
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention');
@@ -861,7 +862,7 @@ test.describe('Composition', () => {
await enableCompositionKeyEvents(page);
await page.keyboard.type('@Luke');
- await waitForSelector(page, '#typeahead-menu ul li');
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
const client = await page.context().newCDPSession(page);
@@ -1284,7 +1285,7 @@ test.describe('Composition', () => {
await enableCompositionKeyEvents(page);
await page.keyboard.type('@Luke');
- await waitForSelector(page, '#typeahead-menu ul li');
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
const client = await page.context().newCDPSession(page);
diff --git a/packages/lexical-playground/__tests__/e2e/File.spec.mjs b/packages/lexical-playground/__tests__/e2e/File.spec.mjs
index 5ee04b6cb3e..224c66d57e5 100644
--- a/packages/lexical-playground/__tests__/e2e/File.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/File.spec.mjs
@@ -15,7 +15,6 @@ import {
initialize,
insertUploadImage,
IS_COLLAB_V2,
- sleep,
test,
waitForSelector,
} from '../utils/index.mjs';
@@ -100,8 +99,10 @@ test.describe('File', () => {
fileChooser.setFiles([filePath]);
});
await click(page, '.action-button.import');
- await sleep(200);
+ // The import reads and parses the file asynchronously; assertHTML retries
+ // until the imported document replaces the empty editor, so no fixed wait
+ // is needed.
await assertHTML(page, expectedHtml);
});
});
diff --git a/packages/lexical-playground/__tests__/e2e/History.spec.mjs b/packages/lexical-playground/__tests__/e2e/History.spec.mjs
index 3242893b94b..7a7d92efe8c 100644
--- a/packages/lexical-playground/__tests__/e2e/History.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/History.spec.mjs
@@ -14,13 +14,13 @@ import {
undo,
} from '../keyboardShortcuts/index.mjs';
import {
+ advanceHistoryClock,
assertHTML,
assertSelection,
enableCompositionKeyEvents,
focusEditor,
html,
initialize,
- sleep,
test,
} from '../utils/index.mjs';
@@ -34,7 +34,7 @@ test.describe('History', () => {
test.skip(isCollab);
await page.focus('div[contenteditable="true"]');
await page.keyboard.type('hello');
- await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency.
+ await advanceHistoryClock(page);
await page.keyboard.type(' world');
await page.keyboard.press('Enter');
await page.keyboard.type('hello world again');
@@ -553,11 +553,11 @@ test.describe('History - IME', () => {
text: 'すし',
});
- await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency.
+ await advanceHistoryClock(page);
await page.keyboard.type(' ');
- await sleep(1050);
+ await advanceHistoryClock(page);
// await page.keyboard.imeSetComposition('m', 1, 1);
await client.send('Input.imeSetComposition', {
@@ -578,7 +578,7 @@ test.describe('History - IME', () => {
text: 'もj',
});
- await sleep(1050);
+ await advanceHistoryClock(page);
// await page.keyboard.imeSetComposition('もじ', 2, 2);
await client.send('Input.imeSetComposition', {
@@ -742,7 +742,7 @@ test.describe('History - IME', () => {
text: 'a',
});
- await sleep(1050);
+ await advanceHistoryClock(page);
await client.send('Input.imeSetComposition', {
selectionStart: 1,
@@ -825,7 +825,7 @@ test.describe('History - IME', () => {
text: 'a',
});
- await sleep(1050);
+ await advanceHistoryClock(page);
await client.send('Input.imeSetComposition', {
selectionStart: 2,
@@ -901,7 +901,7 @@ test.describe('History - IME', () => {
await client.send('Input.insertText', {
text: 'ab',
});
- await sleep(1050);
+ await advanceHistoryClock(page);
await page.keyboard.down('Shift');
await moveLeft(page, 1);
diff --git a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs
index 0769b471a57..f586f590870 100644
--- a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs
@@ -24,6 +24,7 @@ import {
pasteFromClipboard,
test,
waitForSelector,
+ waitForTypeaheadMenuOption,
} from '../utils/index.mjs';
test.describe('Mentions', () => {
@@ -39,10 +40,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -114,10 +112,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -209,10 +204,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -285,10 +277,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -361,10 +350,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -458,10 +444,7 @@ test.describe('Mentions', () => {
await page.keyboard.type('@Luke');
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await assertHTML(
page,
@@ -534,10 +517,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention');
@@ -546,10 +526,7 @@ test.describe('Mentions', () => {
await page.keyboard.type('@Luke');
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention:nth-child(1)');
@@ -558,10 +535,7 @@ test.describe('Mentions', () => {
await page.keyboard.type('@Luke');
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention:nth-child(3)');
@@ -570,10 +544,7 @@ test.describe('Mentions', () => {
await page.keyboard.type('@Luke');
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention:nth-child(5)');
@@ -867,10 +838,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention');
@@ -947,10 +915,7 @@ test.describe('Mentions', () => {
focusPath: [0, 0, 0],
});
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await waitForSelector(page, '.mention');
diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
index 88ed8443c8d..e48d340ada5 100644
--- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
@@ -53,6 +53,7 @@ import {
selectFromFormatDropdown,
sleep,
test,
+ waitForSelector,
YOUTUBE_SAMPLE_URL,
} from '../utils/index.mjs';
@@ -538,7 +539,10 @@ test.describe('Selection', () => {
await pasteFromClipboard(page, {
'text/html': `link`,
});
- await sleep(3000);
+ // Paste inserts the link and places the caret after it in a single update,
+ // so wait for the link to be reconciled rather than sleeping a fixed time
+ // before asserting the (non-retrying) selection.
+ await waitForSelector(page, 'a[href="https://test.com"]');
await assertSelection(page, {
anchorOffset: 4,
anchorPath: [0, 1, 0, 0],
diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
index dd4e9ec276c..6d2d3f504fd 100644
--- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
@@ -22,6 +22,7 @@ import {
undo,
} from '../keyboardShortcuts/index.mjs';
import {
+ advanceHistoryClock,
assertSelection,
assertTableHTML as assertHTML,
assertTableSelectionCoordinates,
@@ -165,17 +166,18 @@ test.describe('Tables', () => {
window.getSelection().setBaseAndExtent(col, 0, col, 0);
});
- // Allow Lexical to process the selection change.
- await sleep(50);
-
// The DOM caret must not be left inside the / region
// (the reconciler should have written it back to the resolved cell).
- const domAnchorNodeName = await evaluate(
- page,
- () => window.getSelection().anchorNode?.nodeName ?? null,
- );
- expect(domAnchorNodeName).not.toBe('COL');
- expect(domAnchorNodeName).not.toBe('COLGROUP');
+ // Poll for the selectionchange -> reconcile round-trip instead of sleeping
+ // a fixed time, which can be too short under load.
+ await expect
+ .poll(() =>
+ evaluate(
+ page,
+ () => window.getSelection().anchorNode?.nodeName ?? null,
+ ),
+ )
+ .not.toMatch(/^COL(GROUP)?$/);
// Typing should land in the first cell, not extend "last".
await page.keyboard.type('X');
@@ -7416,10 +7418,10 @@ test.describe('Tables', () => {
false,
false,
);
- // undo is used so we need to wait for history
- await sleep(1050);
-
- await sleep(1050);
+ // undo is used below, so force a new undo group here. Mode-agnostic:
+ // freezes the @lexical/history clock locally, or resets the Yjs
+ // UndoManager capture window in collab.
+ await advanceHistoryClock(page);
await withExclusiveClipboardAccess(async () => {
const clipboard = await copyToClipboard(page);
diff --git a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs
index 6ebe258ffb5..b1aced44d87 100644
--- a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs
+++ b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs
@@ -14,7 +14,7 @@ import {
html,
initialize,
test,
- waitForSelector,
+ waitForTypeaheadMenuOption,
} from '../utils/index.mjs';
test.describe('Regression test #379', () => {
@@ -24,10 +24,7 @@ test.describe('Regression test #379', () => {
}) => {
await focusEditor(page);
await page.keyboard.type('@Luke');
- await waitForSelector(
- page,
- '#typeahead-menu ul li:has-text("Luke Skywalker")',
- );
+ await waitForTypeaheadMenuOption(page, 'Luke Skywalker');
await page.keyboard.press('Enter');
await assertHTML(
page,
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index 9269a892214..02c2362d513 100644
--- a/packages/lexical-playground/__tests__/utils/index.mjs
+++ b/packages/lexical-playground/__tests__/utils/index.mjs
@@ -704,6 +704,67 @@ export async function sleep(delay) {
await new Promise(resolve => setTimeout(resolve, delay));
}
+/**
+ * Force a new undo group (a "merge boundary") deterministically — a drop-in
+ * replacement for `sleep(mergeWindow + overhead)`. Works in both editor modes
+ * and picks the right mechanism automatically:
+ *
+ * - **Local history** (`@lexical/history`): coalesces consecutive same-type
+ * edits while `now() < prevChangeTime + delay`. We drive the extension's
+ * `now` output signal directly — the lever called out in the task — freezing
+ * it `delay + overheadMs` past its current value. The next edit is then
+ * guaranteed to exceed the merge window (new boundary), while later edits
+ * observe a fixed time so genuinely fast edits still coalesce as in
+ * production. History reads `now` via `peek()`, so reassigning the signal
+ * neither re-registers history nor disturbs in-progress merge bookkeeping.
+ * - **Collab** (Yjs `UndoManager`, which `@lexical/history` is disabled in
+ * favor of): the manager coalesces changes within its own `captureTimeout`
+ * using `Date.now()`, which isn't injectable. Its public `stopCapturing()`
+ * resets the window (`lastChange = 0`) so the next change starts a fresh
+ * stack item — the deterministic equivalent of advancing past the window.
+ * The manager is published on the editor by `useYjsUndoManager`; see
+ * `Symbol.for('@lexical/yjs/UndoManager')`.
+ *
+ * Both replace wall-clock sleeps, which are slow and, under CI load, flaky: a
+ * timer that fires late can coalesce edits meant to stay separate (or vice
+ * versa).
+ *
+ * @param {import('@playwright/test').Page} page
+ * @param {number} [overheadMs] slack added past the local-history `delay`,
+ * mirroring the old `sleep(delay + 50)` convention.
+ */
+export async function advanceHistoryClock(page, overheadMs = 50) {
+ await evaluate(
+ page,
+ overhead => {
+ const editor = document.querySelector(
+ '[data-lexical-editor="true"]',
+ ).__lexicalEditor;
+ // Collab: reset the Yjs UndoManager's capture window.
+ const undoManager = editor[Symbol.for('@lexical/yjs/UndoManager')];
+ if (undoManager) {
+ undoManager.stopCapturing();
+ return;
+ }
+ // Local history: freeze `now` past the merge delay.
+ const builder = editor[Symbol.for('@lexical/extension/LexicalBuilder')];
+ const output = builder?.extensionNameMap.get('@lexical/history/History')
+ ?.state?.output;
+ if (output && !output.disabled.value) {
+ const frozen = output.now.peek()() + output.delay.peek() + overhead;
+ output.now.value = () => frozen;
+ return;
+ }
+ throw new Error(
+ 'advanceHistoryClock: no active undo mechanism found on the editor ' +
+ '(expected the Yjs UndoManager in collab, or an enabled ' +
+ '@lexical/history extension otherwise)',
+ );
+ },
+ overheadMs,
+ );
+}
+
// Fair time for the browser to process a newly inserted image
export async function sleepInsertImage(count = 1) {
return await sleep(1000 * count);
@@ -730,6 +791,33 @@ export async function waitForSelector(page, selector, options) {
await getPageOrFrame(page).waitForSelector(selector, options);
}
+/**
+ * Wait until `optionText` is the *highlighted* (aria-selected) option in the
+ * typeahead / mentions menu, so a subsequent `Enter` deterministically commits
+ * it.
+ *
+ * The mentions lookup is asynchronous and incremental: while e.g. "@Luke" is
+ * being typed, the partial query "Lu" also matches options that sort earlier —
+ * the "Lu" results list "Agent Kallus" (kal**lu**s) at index 0 and only list
+ * "Luke Skywalker" at index 2. Those intermediate result sets resolve on their
+ * own timers, so waiting merely for the option *text* to be present and then
+ * pressing Enter (which commits the highlighted index 0) is racy: under load
+ * the Enter can land while an intermediate list is showing and commit the wrong
+ * option. Waiting for the option to be highlighted is deterministic because the
+ * settled list always highlights the intended option at index 0.
+ *
+ * @param {import('@playwright/test').Page} page
+ * @param {string} optionText
+ * @param {Parameters[1]} [options]
+ */
+export async function waitForTypeaheadMenuOption(page, optionText, options) {
+ await waitForSelector(
+ page,
+ `#typeahead-menu ul li[aria-selected="true"]:has-text("${optionText}")`,
+ options,
+ );
+}
+
export function locate(page, selector) {
return getPageOrFrame(page).locator(selector);
}
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 44c6fbe5992..db8cf4a97bc 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -58,6 +58,16 @@ import {InitialEditorStateType} from '../LexicalComposer';
export type CursorsContainerRef = React.RefObject;
+/**
+ * Well-known key under which the active Yjs {@link UndoManager} is published on
+ * the editor instance (mirroring how `@lexical/extension` attaches its builder
+ * via a `Symbol.for` key). Collab disables `@lexical/history`, so this is the
+ * handle tooling and e2e tests use to force a deterministic undo boundary via
+ * `editor[COLLAB_UNDO_MANAGER]?.stopCapturing()` instead of waiting out the
+ * UndoManager capture timeout.
+ */
+const COLLAB_UNDO_MANAGER = Symbol.for('@lexical/yjs/UndoManager');
+
type OnYjsTreeChanges = (
// The below `any` type is taken directly from the vendor types for YJS.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -560,6 +570,20 @@ function useYjsUndoManager(editor: LexicalEditor, undoManager: UndoManager) {
),
);
});
+ // Publish the UndoManager on the editor (see COLLAB_UNDO_MANAGER) so tooling
+ // and e2e tests can reach it; remove it again when it changes or unmounts.
+ useEffect(() => {
+ const withManager = editor as LexicalEditor &
+ Record;
+ // eslint-disable-next-line react-hooks/immutability
+ withManager[COLLAB_UNDO_MANAGER] = undoManager;
+ return () => {
+ if (withManager[COLLAB_UNDO_MANAGER] === undoManager) {
+ delete withManager[COLLAB_UNDO_MANAGER];
+ }
+ };
+ }, [editor, undoManager]);
+
const clearHistory = useCallback(() => {
undoManager.clear();
}, [undoManager]);