Skip to content

[ci][lexical] Bug Fix: Upgrade @playwright/test to ^1.60.0#8582

Merged
etrepum merged 9 commits into
facebook:mainfrom
etrepum:claude/relaxed-bohr-u1z9Y
May 28, 2026
Merged

[ci][lexical] Bug Fix: Upgrade @playwright/test to ^1.60.0#8582
etrepum merged 9 commits into
facebook:mainfrom
etrepum:claude/relaxed-bohr-u1z9Y

Conversation

@etrepum

@etrepum etrepum commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Description

Upgrading playwright should avoid the occasional CI hang on playwright install microsoft/playwright#40747.

The bundled Firefox jumped from ~144 to 150.0.2 in playwright 1.60.0, which exposed a latent selection-resolution bug: when the DOM caret lands inside an unmanaged DOM region (e.g. a table's <colgroup>/<col>), $internalResolveSelectionPoint treated offset === childNodesLength === 0 as "cursor at end" and walked down to the last descendant of the nearest Lexical ancestor (e.g. the last cell of a table), without ever syncing the DOM caret back to a valid position.

Fixed by:

  • Skipping the moveSelectionToEnd path when childNodesLength === 0 (a void/empty element has no "end").
  • Threading a dirty flag back from $internalResolveSelectionPoint through $internalResolveSelectionPoints into both callers (RangeSelection.applyDOMRange and $internalCreateRangeSelection) so the resulting RangeSelection is marked dirty whenever the DOM caret was in genuinely unmanaged content. The reconciler then writes a valid DOM caret back at the resolved Lexical position.
  • Scoping the dirty flag: it fires only when the DOM node has no __lexicalKey_*, isn't the editor root, and isn't inside a DecoratorNode subtree (decorators own their own selection via $isSelectionCapturedInDecorator). Lexical-managed leaves like <br> (LineBreakNode) and empty decorator containers are unaffected.

Test plan

Before

New regression test Selection placed on a <col> element resolves into the first cell (in packages/lexical-playground/__tests__/e2e/Tables.spec.mjs) fails: after setBaseAndExtent onto a <col> of a 2×2 table whose last cell already contains "last", the DOM caret stays on <col>, the resolved Lexical anchor lands in the last cell, and a subsequent typed X produces "lastX" instead of a new "X" in the first cell.

Selection.spec.mjs:1698 ("shift+arrowdown into a table, when the table is the only node, selects the whole table") fails in Firefox 150 for the same underlying reason.

After

Regression test passes — DOM caret is rewritten out of <col> to the first cell and a typed X lands there; "last" in the last cell is unchanged. All 3384 unit tests pass and tsc is clean.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 28, 2026
@vercel

vercel Bot commented May 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 28, 2026 5:42pm
lexical-playground Ready Ready Preview, Comment May 28, 2026 5:42pm

Request Review

Captures the Firefox behavior where the DOM caret lands on a <col>
inside a table's <colgroup> (an unmanaged DOM region), so we can
validate the workaround.

https://claude.ai/code/session_01R7nxwov2mQJp7t6M2u74nG
…lement

When the DOM caret lands inside an empty/void element such as a <col>
(or any unmanaged element with no children), $internalResolveSelectionPoint
walks up to find a Lexical ancestor and resolves to its first descendant.
The Lexical state was correct after the previous fix, but the DOM caret
was left stranded inside the unmanaged region.

Thread a `dirty` flag back from $internalResolveSelectionPoint through
$internalResolveSelectionPoints into both callers (applyDOMRange and
$internalCreateRangeSelection) so the resulting RangeSelection is marked
dirty and the reconciler writes a valid DOM caret back at the resolved
Lexical position.

Updates the Tables.spec.mjs regression test to also assert that the DOM
anchor is no longer left on <col>/<colgroup> after a forced selection
into the colgroup.

https://claude.ai/code/session_01R7nxwov2mQJp7t6M2u74nG
The previous fix marked the resolved selection dirty whenever the DOM
caret landed on a void/empty HTMLElement, which is overly broad. Void
elements that are themselves Lexical nodes (LineBreakNode <br>, empty
decorator containers) already resolve to a before/after-leaf point in
the parent that maps to a visually identical DOM caret; forcing a DOM
rewrite there is unnecessary churn.

Tighten the condition to also require that the DOM element has no
Lexical key, so dirty fires only for genuinely unmanaged content
(e.g. <col> inside an unmanaged <colgroup>). Decorator inputs are
unaffected because isSelectionWithinEditor already rejects them
before resolution runs.

https://claude.ai/code/session_01R7nxwov2mQJp7t6M2u74nG
claude added 2 commits May 28, 2026 17:37
The previous condition only caught void/empty unmanaged elements like
<col>, but the new DOMSlot use cases that wrap a slot in unmanaged
scaffolding (contenteditable=false labels, badges, headers, etc.) can
have child content. A click on a child div inside that scaffolding has
no Lexical key, but childNodesLength > 0, so dirty wasn't being set and
the DOM caret was left stranded outside the resolved Lexical position.

Drop the void-only restriction and pivot on the immediate DOM node
having no Lexical key (regardless of childNodesLength). Resolution must
walk up in that case, so the resolved Lexical position is by definition
elsewhere; mark dirty so the reconciler syncs the DOM caret to it.

Exempt the editor root element explicitly: it has no __lexicalKey_*
attribute (it's tracked in _keyToDOMMap directly under the 'root' key)
but root-level clicks are a normal flow that don't need a forced DOM
rewrite. Lexical-managed leaves (LineBreakNode, decorator containers)
still have keys and remain unaffected; decorator inputs continue to be
excluded earlier by isSelectionWithinEditor.

https://claude.ai/code/session_01R7nxwov2mQJp7t6M2u74nG
Decorator nodes own their internal DOM and may manage their own
selection state — not only via inputs (which isSelectionCapturedIn-
DecoratorInput already handles earlier), but also via custom widgets,
contenteditable=false UI, or anything else that wants the DOM caret
left where the user/browser put it.

Add $isSelectionCapturedInDecorator(dom) to the dirty-mark guard so a
click on unmanaged content inside a DecoratorNode subtree no longer
forces Lexical to rewrite the DOM caret to a position outside the
decorator. Only genuinely unmanaged DOM that isn't inside a decorator
(e.g. <col> in an unmanaged <colgroup>, or DOMSlot scaffolding on
non-decorator nodes) still triggers DOM resync.

https://claude.ai/code/session_01R7nxwov2mQJp7t6M2u74nG
@etrepum etrepum changed the title [ci] Upgrade @playwright/test to ^1.60.0 May 28, 2026
@etrepum etrepum added this pull request to the merge queue May 28, 2026
Merged via the queue into facebook:main with commit 02e66a7 May 28, 2026
39 checks passed
@etrepum etrepum deleted the claude/relaxed-bohr-u1z9Y branch May 28, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

3 participants