Skip to content

[lexical-text] Bug Fix: Find the first entity match after the previous text node's text, so transforms between two formatted nodes settle - #9655

Draft
zurfyx wants to merge 3 commits into
mainfrom
zurfyx/text-entity-context
Draft

zurfyx wants to merge 3 commits into
mainfrom
zurfyx/text-entity-context

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Parked (10/30)

Draft. The loop in #9637 comes from the two transforms checking a match against different text:

  • the text node's transform finds an entity in its own text;
  • the transform of the plain text node before it checks its end with this node's text after it;
  • an entity node's own transform checks only the text it holds, as createNode gives it.

This pull request makes the forward check use the previous node's text. Each way of making the reverse check agree has broken a case main handles:

  • checking the match's source text alone leaves out a createNode that changes the text (it lower-cases it, say);
  • calling the app's createNode on a detached probe node changes what createNode receives. A factory that reads the source node's parent then throws and loses the typed text, and one that reads the source node's NodeState misses the entity (review: 2 of 2 each; main passes).

Making the checks agree without calling the app's factory on a probe needs an API change: for example an optional pure matches(text, context) callback, or a documented contract that getMatch decides on the text alone, with no lookbehind, and createNode keeps the text. #9637 stays open.


Description

registerLexicalTextEntity's text node transform found the first match in a node in that node's own text, while the previous text node's transform checks a match at its end against the following node's text too. Take plain text between two text nodes of another format (bold "x", "dogcat", bold "y") and type a space after "dog":

  • the transform of "dog cat" made "dog" an entity, since "dog" matches at the start of its own text;
  • the transform of "x" then undid it, since "xdog" has no "dog";
  • the two nodes merged again, and so on, until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the update was lost.

The first match in a node is now found after the text of a plain previous sibling, as that sibling's transform checks it, and so is the check against the next sibling's text that follows it. Later matches in the node are found as before. A first match found that way must also stay an entity under the check the entity's own transform makes: its whole text, as createNode gives it, must be a match. A match that needs the text before it (a lookbehind) is left as text, and the rest of the node is matched as before; one that createNode makes a match of its own (say by lower-casing it) is an entity, as on main.

Closes #9637

Changeset

lexical-text-entity-context.md (@lexical/text, fix): Stop registerLexicalTextEntity transforms from looping when text is typed between two text nodes of another format.

Test plan

registerLexicalTextEntityContext.test.ts (new, 11 tests):

  • the issue's two cases, now with no entity;
  • three controls where the match holds next to the text before it, with "dog" an entity;
  • 2,000 random texts of keywords and other pieces between two bold nodes, a character typed anywhere in the middle one;
  • a lookbehind after a bold "@" (no entity), a lookbehind or a word (the word is an entity), a word boundary after a bold letter (no entity) and after a bold space (an entity), and a lookbehind match that a lower-casing createNode makes a match of its own (an entity).

Before

On main (17154c2):

$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
 × bold "x" | "dogcat" | bold "y", a space after "dog": no entity
 × bold "x" | "dogcat" | bold "dog", an emoji after "dog": no entity
 × random texts between formatted neighbours settle
Error: One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.
      Tests  3 failed | 8 passed (11)

After

$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
      Tests  11 passed (11)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  27696 passed | 204 expected fail | 135 skipped (28035)
$ pnpm run ci-check
Found 0 errors

The issue's repro passes 4 of 4 (main: 2 of 4). The other @lexical/text, hashtag, React and playground unit tests (577) pass.

…s text node's text, so transforms between two formatted nodes settle

## Description

`registerLexicalTextEntity`'s text node transform found the first match in a node in that node's own text, while the previous text node's transform checks a match at its end against the following node's text too. Take plain text between two text nodes of another format (bold "x", "dogcat", bold "y") and type a space after "dog":

- the transform of "dog cat" made "dog" an entity, since "dog" matches at the start of its own text;
- the transform of "x" then undid it, since "xdog" has no "dog";
- the two nodes merged again, and so on, until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the update was lost.

The first match in a node is now found after the text of a plain previous sibling, as that sibling's transform checks it, and so is the check against the next sibling's text that follows it. Later matches in the node are found as before.

Closes #9637

## Test plan

`registerLexicalTextEntityContext.test.ts` (new, 6 tests):
- the issue's two cases, now with no entity;
- three controls where the match holds next to the text before it, with "dog" an entity;
- 2,000 random texts of keywords and other pieces between two bold nodes, a character typed anywhere in the middle one.

### Before

On main (17154c2):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
 × bold "x" | "dogcat" | bold "y", a space after "dog": no entity
 × bold "x" | "dogcat" | bold "dog", an emoji after "dog": no entity
 × random texts between formatted neighbours settle
Error: One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.
      Tests  3 failed | 3 passed (6)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
      Tests  6 passed (6)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  27691 passed | 204 expected fail | 135 skipped (28030)
$ pnpm run ci-check
Found 0 errors
```

The issue's repro passes 4 of 4 (main: 2 of 4). The other `@lexical/text`, hashtag, React and playground unit tests (577) pass.
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 31, 2026 3:51am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 3:51am UTC

Request Review

@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 Oct 31, 2026
@meta-code-review

meta-code-review Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 4:17 AM UTC
@pkg-pr-new

pkg-pr-new Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9655

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9655

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9655

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9655

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9655

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9655

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9655

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9655

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9655

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9655

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9655

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9655

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9655

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9655

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9655

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9655

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9655

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9655

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9655

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9655

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9655

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9655

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9655

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9655

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9655

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9655

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9655

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9655

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9655

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9655

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9655

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9655

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9655

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9655

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9655

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9655

commit: 6a399ad

…s text

## Description

Review of the previous commit: with a matcher whose match needs the text before it (a lookbehind, `/(?<=@)dog/`), the first match in "dog" after a bold "@" was found as "dog" and made an entity. The entity's own transform then checks it on its own text, where there is no "@", and undid it, so the transforms looped again (main never makes it an entity, and settles).

A first match found after the previous sibling's text must now also be a match of its own text. If it is not, it is left as text, and the rest of the node is matched as before. Where a match is split out after such skipped text, it is split at its offset in the node (a match at the start of the rest is not at the start of the node).

## Test plan

`registerLexicalTextEntityContext.test.ts` adds:
- a lookbehind after a bold "@": no entity, and the update goes through;
- a lookbehind or a word: the word is still an entity;
- a word boundary after a bold letter (no entity) and after a bold space (an entity).

### Before

On d8f3d34, the review's repro:

```
$ pnpm exec vitest run --project unit review-9655-Lookbehind.test.ts
Error: One or more transforms are endlessly triggering additional transforms.
      Tests  2 failed (2)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
      Tests  10 passed (10)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  27695 passed | 204 expected fail | 135 skipped (28034)
$ pnpm run ci-check
Found 0 errors
```

- The review's repro: 2 of 2 pass.
- On main, the 4 new cells pass and the 3 earlier ones fail, as before.
…t createNode gives it

## Description

Review of the previous commit: a first match found after the previous sibling's text was kept only if its source text matched on its own. An entity node is checked on its own text, and that is the text `createNode` gives it, which may differ (a `createNode` that lower-cases). With `/dog|(?<=@)DOG/` and a lower-casing `createNode`, "@Dog" after a bold "x" made no entity, where main makes "dog".

The skip now uses the predicate the entity's own transform uses (`$reverseNodeTransform`: a match of the whole text the entity holds). If the source text does not match on its own, the match stays an entity when the text `createNode` gives it does. That text comes from a detached node, which the update's garbage collection removes. A source text that matches on its own is taken as is, as matches found without the previous sibling's text always were.

## Test plan

`registerLexicalTextEntityContext.test.ts` adds: a lookbehind match that a lower-casing `createNode` makes a match of its own (an entity, as on main).

### Before

On d51d2a6:

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
 × a lookbehind match that createNode makes a match of its own: an entity
AssertionError: expected { entities: [], text: 'x@DOG!' } to deeply equal { entities: [ 'dog' ], text: 'x@dog!' }
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityContext.test.ts
      Tests  11 passed (11)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  27696 passed | 204 expected fail | 135 skipped (28035)
$ pnpm run ci-check
Found 0 errors
```

- The review's repro passes 2 of 2, and its lookbehind repro still does.
- On main, the new cell passes (main makes the entity) and the 3 earlier cells fail, as before.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good. The middle node's transform now finds its first match after the previous plain sibling's text. That makes it agree with the check the sibling's own transform already makes against its next sibling, which is what breaks the loop in #9637, and the new tests cover both cases from the issue. The suggestions below are optional and non-blocking.

Optional suggestions

  1. Context after a skipped lookbehind match (packages/lexical-text/src/registerLexicalTextEntity.ts, lines 178-180). After the skip, the rest of the node is matched on its own, without the skipped characters in front of it. Take a matcher like /(?<=@)dog|\bcat\b/ and a bold @ followed by plain dogcat: main leaves dogcat as text, while this branch turns cat into an entity in the middle of the word. Lookbehind matchers already loop on main when @dogcat sits in a single node, so this is an edge case. Still, the comment at lines 176-177 ("the rest of this node is matched as it was") overstates it. You could either note in the registerLexicalTextEntity JSDoc that getMatch should not depend on text before the match (an entity is re-checked on its own text in $reverseNodeTransform), or add a test that pins this case.
  2. createNode as a probe (lines 95-100). $staysEntity can now call the caller's createNode on a detached text node just to see what text it produces. The JSDoc for createNode (line 39) only describes creating the replacement node. A sentence saying it may also be called on a detached node, so it should not have side effects or rely on the node's parent, would help anyone passing a non-trivial createNode.
  3. Repeated getMatch (line 169). On the first iteration, matchAfter(before, remainingText) evaluates getMatch(previousText + text), the same string prevMatch was computed from at line 124. Reusing prevMatch there saves one scan per transform.

The changeset follows CONTRIBUTING.md: a [fix] entry for @lexical/text with the Verify: paragraph a behavior-changing fix needs.

// made an entity and undone again: it is left as text, and the
// rest of this node is matched as it was.
prevMatchLengthToSkip += match.end;
text = remainingText.slice(match.end);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: after this skip, the next iteration matches the rest of the node on its own, without the skipped characters in front of it. With /(?<=@)dog|\bcat\b/ and a bold @ followed by plain dogcat, main leaves dogcat as text, while this branch makes cat an entity in the middle of the word. Lookbehind matchers already loop on main for @dogcat in one node, so this is an edge case. Consider documenting that getMatch should not depend on text before the match, or pinning this case in a test. The comment above ("matched as it was") overstates it a little.

return true;
}
// A detached node, which the update's garbage collection removes.
const made = createNode(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this calls the caller's createNode on a detached node just to read the text it produces. Worth a sentence in the createNode JSDoc (it may be called on a detached node, so it should be free of side effects and not rely on the node's parent).

This branch was successfully deployed

2 active deployments
Preview – lexical — 6a399ad1 Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — 6a399ad1 Deployed Oct 31, 2026 by vercel[bot]
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.

1 participant