Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
df62fb7 to
8d2c2a2
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `registerLexicalTextEntityJoin.test.ts` (new, 5 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, the long-session shape (the block before ends with a tab, all with NodeState), and a hashtag with `MarkStateExtension` comment ids joined onto italic text. Without #9712 the first three lose the NodeState (as #9686 did); with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text with its NodeState. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 5 passed (5) ```
There was a problem hiding this comment.
The change looks good: the NodeState carry fixes #9703 for every path that turns an entity into text, the new tests fail on main and pass here, and the token-mode loop raised in #9702's review (#a b with a token createNode) now settles. The suggestions below are optional and non-blocking.
- Keyword-style matchers and #9637's loop (
packages/lexical-text/src/registerLexicalTextEntity.ts:252). Because a demoted entity now keeps its NodeState, it no longer merges with neighbouring text that has none, which is the layout of #9637's endless-transform loop. With #9637's own matcher (/(^|[^a-z#])(cat|dog)(?=[^a-z]|$)/, normal mode, like the playground'sKeywordsExtension): boldcat|dogcat| keyworddogwith any NodeState, caret afterdogindogcat, type a space.mainapplies the edit (dropping the keyword's NodeState); this branch throws "One or more transforms are endlessly triggering additional transforms". WithMarkStateExtensionids on the keyword,mainalready throws there, and the description names this trade-off. Since #9637 was closed by #9686 and stayed closed after its revert, reopening it (or landing the #9704 re-land right after this) would keep the case tracked. Adding that matcher as a third kind inregisterLexicalTextEntityNodeStateProperty.test.ts:176would pin it. - The split-off exemption is per update (
registerLexicalTextEntity.ts:162). Inserting#barafter#foo(NodeStatec1) in one update leaves#barwithoutc1. Typing the same characters one keystroke at a time gives#barc1, because#bhas already inheritedc1fromsplitTextin an earlier update. WithMarkStateExtensionboth end without ids. A sentence in that comment (or the changeset) saying the exemption covers one update would set expectations for apps with their own NodeState. - Changeset wording (
.changeset/lexical-text-entity-node-state.md:7). A kept token or segmented entity takes the following text only when it would merge (same format, style, detail and NodeState,registerLexicalTextEntity.ts:313). CONTRIBUTING.md asks a behaviour-changing fix to say what to test underVerify:, so it could also mention that the former hashtag stays a separate text node next to text without its NodeState. - Nit:
joining two blocks across text of another format keeps the NodeState of every nodealso passes onmain, where the hashtag stays a hashtag, because it does not assert the node type. That is fine as a guard for the re-land, but it does not test this change.
| .setFormat(node.getFormat()) | ||
| .setStyle(node.getStyle()) | ||
| .setDetail(node.getDetail()); | ||
| $carryNodeState(node, textNode); |
There was a problem hiding this comment.
Optional: with the entity's NodeState kept here, the text no longer merges with neighbours that have none, which recreates #9637's layout for keyword-style matchers. With #9637's cat|dog matcher, bold cat | dogcat | keyword dog (any NodeState), typing a space after dog throws "One or more transforms are endlessly triggering additional transforms" on this branch while main applies it. Since #9637 stayed closed after #9686's revert, reopening it (or landing the #9704 re-land next) would keep this tracked.
| // `splitText` gives such a piece a copy of the entity's NodeState, but its text | ||
| // was inserted after the entity (typed or pasted), so that NodeState is not its | ||
| // own, and no entity made from it (of this registration or another) takes it. | ||
| const splitOffInUpdate = new WeakMap<EditorState, Set<NodeKey>>(); |
There was a problem hiding this comment.
Optional: this set lives for one pending editor state, so #bar inserted after #foo (NodeState c1) in one update leaves #bar without c1, while typing it one keystroke at a time gives #bar c1 ( #b already inherited c1 from splitText in an earlier update). A sentence saying the exemption covers one update would help apps with their own NodeState.
| } | ||
|
|
||
| describe('registerLexicalTextEntity keeps NodeState (property)', () => { | ||
| for (const kind of ['hashtag', 'mention'] as Kind[]) { |
|
|
||
| [fix] `registerLexicalTextEntity` keeps the NodeState of a text entity (a hashtag, a keyword) that stops matching and becomes text, such as comment ids stored as NodeState, instead of dropping it | ||
|
|
||
| The NodeState also comes back when that text is made an entity again in the same update, and plain text keeps its NodeState when it becomes an entity (state that `createNode` gives the entity wins). A token or segmented entity that text follows keeps its NodeState as well: it is turned back into text with it, and after twice in one update (or in updates batched into one commit) it is kept and takes the text its match extends into, instead of looping forever. |
There was a problem hiding this comment.
Optional wording: a kept token or segmented entity takes the following text only when that text would merge with it (same format, style, detail and NodeState, registerLexicalTextEntity.ts:313). Per CONTRIBUTING.md's Verify: rule, it could also say the former hashtag stays a separate text node next to text without its NodeState.
8d2c2a2 to
c1b3976
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Two more changes come with the re-land, for orders of transforms that #9686 alone reached before the other checks: - A normal-mode entity grows over the text after it (takes the characters its match extends into) only when it is an entity the transforms keep: not right after another text entity (the two are turned back into text by the rule for adjacent entities), and not after a simple text node that it is no entity after (`isEntityAfter`). Otherwise it is turned back into text first, rather than grown and then turned into text with the next node's characters, which then lost their format and NodeState (two hashtags next to each other before italic text, a `createNode` that changes the case). - Two entities next to each other are turned back into text, as before, but when the same entity comes back to that a third time in one update it is kept (the same counter as the token breaker, counted apart): entities of two registrations that touch (a segmented `$B` next to a token `@x`) looped forever on `main`; now they settle, kept as they are. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `registerLexicalTextEntityJoin.test.ts` (new, 7 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, a block before that ends with a tab (all with NodeState), a hashtag with `MarkStateExtension` comment ids joined onto italic text, two hashtags turned into text before italic text with NodeState (the text keeps its format, NodeState and the selection), and typing before a hashtag that a segmented and a token entity follow (settles, with a bounded number of `getMatch` calls). With #9686 alone the first three lose the NodeState and the `MarkStateExtension` one loses its comment ids; with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text and keeps them. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 7 passed (7) ```
There was a problem hiding this comment.
Thanks for tracking this down. Keeping a hashtag's NodeState when it turns back into text fixes #9703, and the new tests reproduce it: they fail on main and pass here. One issue needs a fix before this can merge, plus a few optional suggestions.
Needs a fix: a hashtag made of text pasted at the edge of a MarkStateExtension mark now joins the mark (packages/lexical-text/src/registerLexicalTextEntity.ts, lines 472-475)
MarkStateExtension documents that text inserted at the edge of a marked run does not join it, like MarkNode (its docblock in packages/lexical-mark/src/MarkStateExtension.ts). With the new promotion carry, pasting #tag right after see marked c1, or #tag right before it, gives:
main: "see"[c1] " " "#tag" " more"
here: "see"[c1] " " "#tag"[c1] " more"
The pasted characters land in the marked text node first, the hashtag is split off with a copy of the ids and now keeps them, and MarkStateExtension strips the edge-inserted space but cannot split the hashtag. The same happens for one multi-character text insertion and for insertText from code; typing one character at a time is unaffected. The splitOff exception covers text split off the end of an entity, not text added to marked plain text. With MarkNode the hashtag stays unmarked on both branches. Two ways out: don't carry onto an entity the NodeState of characters inserted in the same update, or have MarkStateExtension's inserted-text rule treat an atomic node made only of edge-inserted characters as unmarked. A test next to the existing hashtag edge cases in MarkStateExtension.test.ts ("text inserted after a marked hashtag is not marked, like MarkNode") would lock it in.
Optional suggestions (non-blocking)
- After typing a character before a hashtag that carries NodeState and deleting it again,
#tagnow stays plain text; onmainit becomes a hashtag again, without its NodeState. The edited neighbour's transform returns atmatch === nullwith anextMatchand does not revisit it. That matches what already happens for a hashtag in another format, so it is a fair trade-off, but it is worth a test and a line in the changeset. - The changeset could say that text keeping an entity's NodeState no longer merges with neighbours that have other NodeState, since that changes saved JSON; CONTRIBUTING.md asks a fix that changes observable output to say so and what to verify.
- There are now two carry helpers with different rules:
$carryNodeStateinpackages/lexical/src/LexicalNodeState.tsoverwrites keys the target already holds, and the private one inregisterLexicalTextEntity.tskeeps them. Sharing one, or a comment pointing each at the other, would keep them from drifting.
| if (splitOff === null) { | ||
| // The NodeState of the text becomes the entity's | ||
| $carryNodeState(nodeToReplace, replacementNode); | ||
| } |
There was a problem hiding this comment.
This carry makes a hashtag pasted at the edge of a MarkStateExtension mark join it: pasting #tag right after see[c1] lands in the marked node first, so the split-off #tag piece has a copy of the ids and the new hashtag keeps them ("see"[c1] " " "#tag"[c1]; on main and with MarkNode it stays unmarked). MarkStateExtension documents that text inserted at the edge of a marked run does not join it. splitOff only covers text split off an entity's end, not text added to marked plain text.
c1b3976 to
bcdd60e
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Two more changes come with the re-land, for orders of transforms that #9686 alone reached before the other checks: - A normal-mode entity grows over the text after it (takes the characters its match extends into) only when it is an entity the transforms keep: not right after another text entity (the two are turned back into text by the rule for adjacent entities), and not after a simple text node that it is no entity after (`isEntityAfter`; when that cannot tell, after too many matches in the text before, it stays as it is without growing). Otherwise it is turned back into text first, rather than grown and then turned into text with the next node's characters, which then lost their format and NodeState (two hashtags next to each other before italic text, a `createNode` that changes the case). - Two entities next to each other are turned back into text, as before, but when the same entity comes back to that a third time in one update it is kept (the same counter as the token breaker, counted apart): entities of two registrations that touch (a segmented `$B` next to a token `@x`) looped forever on `main`; now they settle, kept as they are. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts` (new, `test.fails`, #9716): two peers; a comment removed by one peer and both peers' undos leave the comment on every block after it. A `@lexical/y` bug (undo restores a mark opener without its closer), on `main` too with this test; this change reaches it more often because entities keep their comments. Pinned so it flips when #9716 is fixed. `registerLexicalTextEntityJoin.test.ts` (new, 9 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, a block before that ends with a tab (all with NodeState), a hashtag with `MarkStateExtension` comment ids joined onto italic text, two hashtags turned into text before italic text with NodeState (the text keeps its format, NodeState and the selection), typing before a hashtag that a segmented and a token entity follow (settles, with a bounded number of `getMatch` calls), and a hashtag after text with 7 and 8 earlier matches (the most `isEntityAfter` checks) that does not grow over the commented italic text after it. With #9686 alone the first three lose the NodeState and the `MarkStateExtension` one loses its comment ids; with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text and keeps them. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 9 passed (9) ```
There was a problem hiding this comment.
Thanks for this. Keeping an entity's NodeState when it turns back into text fixes #9703, and the new tests fail on main and pass here. One interaction needs a fix before this merges; the rest are optional suggestions.
Needs a fix: a hashtag in text pasted right after a MarkStateExtension mark now joins the mark (packages/lexical-text/src/registerLexicalTextEntity.ts, line 474)
MarkStateExtension documents that text inserted at the edge of a marked run does not join it, like MarkNode. See its docblock in packages/lexical-mark/src/MarkStateExtension.ts and the "Inserted text" section of packages/lexical-website/docs/collaboration/comments.md. Set up RichTextExtension, HashtagExtension and MarkStateExtension, mark see as c1, put the caret at its end and paste #tag. Inserting it as one CONTROLLED_TEXT_INSERTION_COMMAND, or with insertText from code, does the same:
main: "see"[c1] " " "#tag" " more"
here: "see"[c1] " " "#tag"[c1] " more"
Pasting , see #launch gives #launch the mark in the same way. The pasted characters land in the marked text node, the hashtag transform splits #tag off with a copy of the ids, and the new promotion carry hands them to the hashtag. MarkStateExtension then strips the edge-inserted space but leaves the new hashtag the ids it was given. Typing one key at a time is unaffected, and so is MarkNode. The splitOff exemption covers text split off an entity's end, not text inserted into marked plain text. There are two ways out. One is to skip the promotion carry for NodeState that the characters only got by landing in a marked node during this update. The other is to have MarkStateExtension's inserted-text rule give no ids to an unsplittable node made only of edge-inserted characters. A test next to text inserted after a marked hashtag is not marked, like MarkNode in MarkStateExtension.test.ts would lock it in.
Optional suggestions
- Keyword-style matchers and #9637. Take #9637's matcher with bold
cat|dogcat| a keyworddogcarrying any NodeState. Typing a space afterdogindogcatthrows "One or more transforms are endlessly triggering additional transforms" here, whilemainapplies the edit and drops the NodeState. The description names this trade-off. #9637 is closed although #9686 was reverted, so reopening it, or tracking the case on #9704, would keep it visible. - A test for the plain token case. On
main, a token entity followed by ordinary text with no NodeState (hi|@bob|x) already throws the same error, and this change settles it. All the new token tests put NodeState on the entity, so a plain case would pin the broader fix. The description could say so too. joining two blocks across text of another format keeps the NodeState of every nodealso passes onmain, because it does not assert the node type.- Changeset. CONTRIBUTING.md asks a fix that changes observable behaviour to say what to check under
Verify:. That paragraph could add that text keeping an entity's NodeState stays a separate node next to text with other NodeState, and that token or segmented entities which used to loop now settle. - One carry helper.
$carryNodeStatenow exists inpackages/lexical/src/LexicalNodeState.tsand privately inregisterLexicalTextEntity.ts, andhaveEquivalentNodeStatemirrors core'snodeStatesAreEquivalent. Exporting oneINTERNAL_helper fromlexicalwould keep them from drifting.
| .setDetail(nodeToReplace.getDetail()); | ||
| if (splitOff === null) { | ||
| // The NodeState of the text becomes the entity's | ||
| $carryNodeState(nodeToReplace, replacementNode); |
There was a problem hiding this comment.
This carry makes a hashtag pasted right after a MarkStateExtension mark join it: pasting #tag after see[c1] lands in the marked node, the split-off #tag piece has a copy of the ids, and the new hashtag keeps them ("see"[c1] " " "#tag"[c1]; on main, and with MarkNode, it stays unmarked). MarkStateExtension documents that text inserted at the edge of a marked run does not join it (packages/lexical-website/docs/collaboration/comments.md, "Inserted text"). The splitOff exemption only covers text split off an entity's end.
| .setFormat(node.getFormat()) | ||
| .setStyle(node.getStyle()) | ||
| .setDetail(node.getDetail()); | ||
| $carryNodeState(node, textNode); |
There was a problem hiding this comment.
Optional: with the NodeState kept here, a demoted keyword no longer merges with neighbours that have none, which recreates #9637's layout. With #9637's cat|dog matcher, bold cat | dogcat | keyword dog (any NodeState), typing a space after dog throws "One or more transforms are endlessly triggering additional transforms" here, while main applies it. Since #9637 stayed closed after #9686 was reverted, reopening it or tracking it on #9704 would keep this visible.
bcdd60e to
8f7d2b4
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Two more changes come with the re-land, for orders of transforms that #9686 alone reached before the other checks: - A normal-mode entity grows over the text after it (takes the characters its match extends into) only when it is an entity the transforms keep: not right after another text entity (the two are turned back into text by the rule for adjacent entities), and not after a simple text node that it is no entity after (`isEntityAfter`; when that cannot tell, after too many matches in the text before, it stays as it is without growing, and the text after it is parsed as usual). Otherwise it is turned back into text first, rather than grown and then turned into text with the next node's characters, which then lost their format and NodeState (two hashtags next to each other before italic text, a `createNode` that changes the case). - Two entities next to each other are turned back into text, as before, but when the same entity comes back to that a third time in one update it is kept (the same counter as the token breaker, counted apart): entities of two registrations that touch (a segmented `$B` next to a token `@x`) looped forever on `main`; now they settle, kept as they are. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts` (new, `test.fails`, #9716): two peers; a comment removed by one peer and both peers' undos leave the comment on every block after it. A `@lexical/y` bug (undo restores a mark opener without its closer), on `main` too with this test; this change reaches it more often because entities keep their comments. Pinned so it flips when #9716 is fixed. `registerLexicalTextEntityJoin.test.ts` (new, 11 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, a block before that ends with a tab (all with NodeState), a hashtag with `MarkStateExtension` comment ids joined onto italic text, two hashtags turned into text before italic text with NodeState (the text keeps its format, NodeState and the selection), typing before a hashtag that a segmented and a token entity follow (settles, with a bounded number of `getMatch` calls), and a hashtag after text with 7 and 8 earlier matches (the most `isEntityAfter` checks) that does not grow over the commented italic text after it, while a later hashtag in that text is still made. With #9686 alone the first three lose the NodeState and the `MarkStateExtension` one loses its comment ids; with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text and keeps them. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 11 passed (11) ```
There was a problem hiding this comment.
Thanks, this looks good. Carrying NodeState through $replaceWithSimpleText, through promotion, and through the two core segmented-node paths fixes #9703, and the new tests fail on main and pass here. The loop guard also lets token and segmented entities that text follows settle where main hit the endless-transform invariant. The suggestions below are optional and non-blocking.
- Keyword-style matchers and #9637 (
packages/lexical-text/src/registerLexicalTextEntity.ts:307). Kept NodeState now separates text the way a format does, so keyword-style entities (canInsertTextBefore/After()returningfalse, a word-boundary regex like the playground's keywords) reach #9637's loop in layoutsmainmerged. One that throws on the first transform pass here and settles onmain, all in one format:" cat"(state n0), keyword"cat"(n1)," cat"(n1), keyword"dog", keyword"cat". Hashtags are not affected. The PR body already names this tradeoff; landing after the #9704 re-land, or pinning one such layout in a test, would make the expected behavior explicit. - One segmented-edit path still drops NodeState (
packages/lexical/src/LexicalUtils.ts:1101-1105).$updateTextNodeFromDOMContentreplaces an edited segmented node with$createTextNode(text), without format, style, detail or NodeState, when the edit arrives as a DOM text mutation instead ofbeforeinput. This predates the PR; the samesetDetail+$carryNodeStatepair would make the changeset's "editing inside a segmented node keeps its NodeState" hold there too. - Changeset wording (
.changeset/lexical-text-entity-node-state.md). CONTRIBUTING.md asks the entry to describe what changes for people who use Lexical: the core paths now also keep the segmented node's detail (directionless, unmergeable), and NodeState an entity class declares in its own$confignow reaches plain text. A sentence on each would help apps that recognise entities by a NodeState key. - Nit:
$wouldMerge(registerLexicalTextEntity.ts:182) compares the whole detail, while core merging only checks the unmergeable bit, so a directionless token entity kept by the guard never takes the text after it. - Nit:
$carryNodeStatenow exists in bothpackages/lexical/src/LexicalNodeState.tsandregisterLexicalTextEntity.ts; exporting the core one as anINTERNAL_helper would keep the two rules from drifting. - Nit (test plan): AGENTS.md asks for the real Before output of each command; the three new
LexicalSelection.test.tscases are described as failing onmain, but their Before output is not pasted.
| .setFormat(node.getFormat()) | ||
| .setStyle(node.getStyle()) | ||
| .setDetail(node.getDetail()); | ||
| $carryNodeState(node, textNode); |
There was a problem hiding this comment.
Optional: with the state kept here, text that this entity becomes no longer merges with neighbours that hold other NodeState, so keyword-style matchers (canInsertTextBefore/After() false, word-boundary regex) can reach #9637's loop in layouts main merged. Example, one format: " cat" (n0), keyword "cat" (n1), " cat" (n1), keyword "dog", keyword "cat" throws on the first transform pass here and settles on main. The PR body names this; sequencing after the #9704 re-land, or pinning such a layout in a test, would make it explicit.
| return ( | ||
| a.getFormat() === b.getFormat() && | ||
| a.getStyle() === b.getStyle() && | ||
| a.getDetail() === b.getDetail() && |
There was a problem hiding this comment.
Nit: core merging ($canSimpleTextNodesBeMerged plus the unmergeable check in $normalizeTextNode) does not compare the directionless bit, so a directionless token entity kept by the guard never takes the text after it. Comparing only the unmergeable flag here would mirror the core rule.
|
|
||
| [fix] `registerLexicalTextEntity` keeps the NodeState of a text entity (a hashtag, a keyword) that stops matching and becomes text, such as comment ids stored as NodeState, instead of dropping it | ||
|
|
||
| The NodeState also comes back when that text is made an entity again in the same update, and plain text keeps its NodeState when it becomes an entity (state that `createNode` gives the entity wins). A token or segmented entity that text follows keeps its NodeState as well: it is turned back into text with it, and after twice in one update (or in updates batched into one commit) it is kept (taking the text its match extends into only when that text would merge with it), instead of looping forever. Editing inside a segmented node (typing, or removing a range that starts or ends in it) keeps its NodeState too. |
There was a problem hiding this comment.
Optional: the core segmented paths now also keep the node's detail (directionless, unmergeable), and NodeState an entity class declares in its own $config now reaches plain text. A sentence on each would tell apps that recognise entities by a NodeState key to check the node type too (CONTRIBUTING.md: write the entry for someone who uses Lexical).
8f7d2b4 to
c93a5a6
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Two more changes come with the re-land, for orders of transforms that #9686 alone reached before the other checks: - A normal-mode entity grows over the text after it (takes the characters its match extends into) only when it is an entity the transforms keep: not right after another text entity (the two are turned back into text by the rule for adjacent entities), and not after a simple text node that it is no entity after (`isEntityAfter`; when that cannot tell, after too many matches in the text before, it stays as it is without growing, and the text after it is parsed as usual). Otherwise it is turned back into text first, rather than grown and then turned into text with the next node's characters, which then lost their format and NodeState (two hashtags next to each other before italic text, a `createNode` that changes the case). - Two entities next to each other are turned back into text, as before, but when the same entity comes back to that a third time in one update it is kept (the same counter as the token breaker, counted apart): entities of two registrations that touch (a segmented `$B` next to a token `@x`) looped forever on `main`; now they settle, kept as they are. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts` (new, `test.fails`, #9716): two peers; a comment removed by one peer and both peers' undos leave the comment on every block after it. A `@lexical/y` bug (undo restores a mark opener without its closer), on `main` too with this test; this change reaches it more often because entities keep their comments. Pinned so it flips when #9716 is fixed. `registerLexicalTextEntityJoin.test.ts` (new, 11 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, a block before that ends with a tab (all with NodeState), a hashtag with `MarkStateExtension` comment ids joined onto italic text, two hashtags turned into text before italic text with NodeState (the text keeps its format, NodeState and the selection), typing before a hashtag that a segmented and a token entity follow (settles, with a bounded number of `getMatch` calls), and a hashtag after text with 7 and 8 earlier matches (the most `isEntityAfter` checks) that does not grow over the commented italic text after it, while a later hashtag in that text is still made. With #9686 alone the first three lose the NodeState and the `MarkStateExtension` one loses its comment ids; with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text and keeps them. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 11 passed (11) ```
…hen they become text ## Description `registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none. What carries, and why: - **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before. - **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with two exceptions, both characters that were just inserted into a node and so only have a copy of its NodeState: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag), and an entity made only of text inserted at the start or the end of a node that existed in this update, when no entity was turned into text in that block in the update (pasting ` #tag` right after commented text: the hashtag does not join the comment, as on `main` and as `MarkStateExtension`'s rule for text inserted at a comment's edge says). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back. - **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next. - The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either. - **Core: editing inside a segmented node** replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way (and its detail, such as `unmergeable`, which `$replaceWithSimpleText` kept): typing inside it (outside composition, `LexicalSelection`) and removing a range that starts or ends inside it (`LexicalCaretUtils`). Both now keep them (detail, and NodeState with `$carryNodeState`, internal to `lexical`, with the same rules as above; keys the new node already holds, such as state a registered `TextNode` replacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here. Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before. Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path. Fixes #9703 ## Test plan `.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`). `MarkStateExtension.test.ts`: a hashtag pasted or inserted at the edge of a comment (after it, and before it) is not added to it, with `MarkStateExtension` and with `MarkNode` (as on `main`). `LexicalSelection.test.ts` (3 tests, each fails on `main`): typing inside a segmented node keeps its NodeState and detail; a registered `TextNode` replacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState and detail of what is left. `registerLexicalTextEntityNodeState.test.ts` (new, 24 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, text inserted after a hashtag not taking the copy, and a hashtag made only of inserted text not taking it; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format. `registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`). Before (main): ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it) × deleting the # of a hashtag keeps its NodeState (reverse transform, no match) × a hashtag that becomes text merges into neighbors with the same NodeState × plain text with NodeState that becomes a hashtag keeps it × state that createNode gives the entity wins over the text it replaces × a reference-typed state value is carried as the same object × NodeState read from JSON (not parsed yet) is carried and exported × NodeState declared in the entity $config (nested and flat) is carried to the text × a key the plain text type declares with another config is parsed with that config × a hashtag inside a MarkNode stays inside it when it becomes text × undo and redo of a demotion restore the entity and the text with their NodeState × the text transform: the entity before it stops matching with its text (prevMatch null) × with the same NodeState as the text: it keeps it and takes the text × with other NodeState than the text: kept with its NodeState, the text stays apart × typed text after it: the selection stays after the typed text × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own × with a value object distinct from the text after it, equal as JSON: each keeps its own × the text transform: the entity after it stops matching (nextMatch null) × the reverse transform: adjacent entities both become text × the reverse transform: an entity followed by an entity × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters Tests 22 failed | 3 passed (25) ``` After: ``` $ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history Tests 1378 passed (1378) $ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts Tests 103 passed (103) $ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters ``` The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked). A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
c93a5a6 to
5c7a188
Compare
…ttle between text nodes of other formats (re-land of #9686) This re-lands #9686, reverted by #9702 because it turns more entities back into text (every edit that leaves a hashtag after text in another format), and turning an entity into text dropped its NodeState (#9703): joining a block that starts with a commented hashtag onto text in another format lost the comment (#9704). #9712 fixes #9703 (entities keep their NodeState when they become text) and brings the loop breaker for token and segmented entities; this PR is #9686 on top of it, unchanged otherwise, plus #9704's case and the long-session join as tests. Review the second commit; the first is #9712. `registerLexicalTextEntity` decides whether a match at the start of a text node is an entity in two places that disagreed: - the node's own transform made the first match in its own text an entity, without the text node before it; - the transform of a simple text node before it checks the entity that follows it with its own text in front, and turns it back into text when that is no match. Take plain text between two text nodes of another format (bold `x`, `dogcat`, bold `y`) and type a space after `dog`. The first transform made `dog` an entity, the second undid it (`xdog` has no match), the nodes merged again, and so on until Lexical threw "One or more transforms are endlessly triggering additional transforms" and the edit was lost (#9637). Now a match at the start of a node is left as text when the simple text node before it would turn it straight back into text, that is when the match's text after that node's text is no match (`getMatch(previousText + matchText) === null`, the check that node's transform makes). It is still made an entity when the first match of both nodes' text is this match, which a match that depends on the text after it can be (the entity `createNode` makes may then match on its own). Any match right after one left as text stays text too, as it does after a match next to another entity. The match is still found in the node's own text, so the entity's own transform agrees as before, and the extra check is one `getMatch` call (the first match is already computed), made only for a match at the start of a node after a simple text node. Where that transform returned early, before its loop (its text has no match after the text node before it, or the first starts in that node), it never checked the entity after it, as its loop does. So typing a letter right before a hashtag that follows text in another format left the hashtag an entity (`b#tag`), which a fresh parse of the same text does not make. In that early return the transform now turns such an entity back into text unless the transforms would keep it there (below). A match left as text at the start of the next node stays text until that node is edited, as before. Whether the transforms keep an entity right after a simple text node is one rule, used by both transforms and by the loop's check of the next node: the first match from that node's text on, passing the matches of its text (at most 8, as the loop passes over them; past that, nothing is changed), must start where the entity does, with the text after the entity in the node it was split from (the simple text node after it in the same format, style and detail), as the check that made it an entity found it; and that match must still hold with the text of any other text node after it, as the check that keeps a match at the end of a node from becoming an entity found it. So a match that needs the text after it (`/#[a-z]+(?= )/`) is kept where it was made, and text after it in another format, or a token node, does not keep an entity that a fresh parse would not make. An entity's own transform, when the transforms no longer keep it after the simple text node before it (the text after it changed, say), marks that node dirty, once per node for each pending editor state (other entities' transforms may leave it dirty again), so it is turned back into text in the same update rather than at the next edit of the text before it. A token or segmented entity with text after it in its node looped on `main`: the transform of that text turns it back into text, the text merges, and the transform makes it again. #9712 keeps such an entity the third time it comes back to be turned into text in one update, and this PR keeps that rule. Two more changes come with the re-land, for orders of transforms that #9686 alone reached before the other checks: - A normal-mode entity grows over the text after it (takes the characters its match extends into) only when it is an entity the transforms keep: not right after another text entity (the two are turned back into text by the rule for adjacent entities), and not after a simple text node that it is no entity after (`isEntityAfter`; when that cannot tell, after too many matches in the text before, it stays as it is without growing, and the text after it is parsed as usual). Otherwise it is turned back into text first, rather than grown and then turned into text with the next node's characters, which then lost their format and NodeState (two hashtags next to each other before italic text, a `createNode` that changes the case). - Two entities next to each other are turned back into text, as before, but when the same entity comes back to that a third time in one update it is kept (the same counter as the token breaker, counted apart): entities of two registrations that touch (a segmented `$B` next to a token `@x`) looped forever on `main`; now they settle, kept as they are. Each of these checks is a few `getMatch` calls, bounded by the 8 matches passed over, only on the paths above. A matcher whose match does not hold on the entity's own text (`/dog(?=cat)/`, with `createNode` keeping the text) still loops, on plain text too, as on `main`: the entity's own transform turns it back into text every time. `registerLexicalTextEntity` needs an entity's text to be a match on its own. Two registrations whose entities end up touching can still loop through the rule that turns two adjacent entities into text (with a hashtag and a segmented upper-case matcher, `#xabDOG` loops on `main` from a fresh parse); with one registration, or matches that cannot touch, this does not arise. The source text of an entity is told by format, style and detail; NodeState is not compared (text keeps its NodeState when it becomes an entity, but `createNode` may give the entity its own). No API is added or changed. Entities can differ from before only next to the boundary between a simple text node and the node after it, where one of the checks above was skipped or disagreed with the other. Closes #9637 Closes #9704 `.changeset/lexical-text-entity-settle.md` (`@lexical/text`, patch, `[fix]`). `registerLexicalTextEntitySettle.test.ts` (new, 41 tests): the issue's two cases and its controls; a later match in the node; a lookbehind-style matcher; a hashtag-like matcher after a formatted letter and a formatted space; two entities next to each other; `dogcat` between every pair of four formats and four neighbour texts, with four typed characters; IME composition; pasting formatted runs; bolding the text before an entity; typing a letter before an entity after text in another format; a matcher whose earlier match must end where the text ends; matchers that need the text after a match (with a match at the start of a node, after typing, and after removing the text after an entity), one with a `createNode` that upper-cases; text after an entity in another format; removing the text between an entity and a token after it; a token or segmented entity with text after it in its node (loops on `main`), and one after a match left as text, with the nodes each settles on; an update that fails not counting toward the next; two registrations, one making token or segmented entities, in both orders, with the nodes they settle on; text after an entity with another detail; an entity after a node with an earlier match; an entity checked again in a later update; one dirty node per keystroke in text before a formatted hashtag, with and without an earlier match in that text (so history merges typing, as on `main`); a match at the start of the next node that typing makes valid, made an entity when that node next changes; `createNode` factories that lower-case, upper-case or add text; and a bounded number of `getMatch` calls per keystroke in a long node before an entity, however long. Then, for three matchers, 2,000 random layouts each (override with `TEXT_ENTITY_SETTLE_SEEDS`): - a fresh parse settles, and transforming every node again leaves it as it is; - typing or deleting anywhere in text between two nodes of other formats (#9637's shape) settles on the entities a fresh parse of the result finds; - sessions of 4 edits (typing or deleting anywhere, bolding a node) settle, and so does transforming every node again after each (a guard against new loops: these pass on `main` too). `Hashtags.spec.mjs`: the playground's keywords, bold `x`, `congratscongrats`, bold `y`, and a space after the first `congrats` (the loop on `main`: it needs a later match that the next node turns back into text, which a hashtag cannot be, since text after a hashtag only extends it); and hashtags typed after a bold letter and between bold text, with the exact text. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts × bold "x" | "dogcat" | bold "y", a space after "dog": settles, no entity × bold "x" | "dogcat" | bold "dog", an emoji after "dog": settles, no entity × entity at the edges of a node, every format combination around it × IME composition between two formatted nodes settles, and so does its end × typing a letter before an entity after text in another format turns it back into text × a match at the start of a node that needs the text after it settles after a formatted node × an entity after a node with an earlier match is checked too × removing the text between an entity and a token after it turns the entity back into text × an entity is checked again in each update × typing that makes a match at the start of the next node one leaves it text until that node changes × a token or segmented entity with text after it in its node settles × two registrations, one making token or segmented entities, settle × keyword: 2000 random layouts parse, and parse again the same × keyword: 2000 random edits between two formatted nodes settle on a fresh parse × tag: 2000 random edits between two formatted nodes settle on a fresh parse × lookbehind: 2000 random layouts parse, and parse again the same × lookbehind: 2000 random edits between two formatted nodes settle on a fresh parse + "seed 231 [\"_😀gdo\",\"*cat dog\",\"docatdog \",\"_gdo!g\"]: Error: One or more transforms are endlessly triggering additional transforms. Ma", + "seed 956 [\"_😀\",\"cat \",\"*_y\"] [[\"text\",\"😀\",2],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] type \"d\" at 1:0: [[\"text\",\"😀\",2],[\"text\",\"d\",0],[\"test-keyword\",\"cat\",0],[\"text\",\" \",0],[\"text\",\"y\",3]] != [[\ Tests 17 failed | 24 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459 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. ``` ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SETTLE_SEEDS=60000 pnpm exec vitest run --project unit --testTimeout=0 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntitySettle.test.ts Tests 41 passed (41) $ TEXT_ENTITY_SEED=30449 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TextEntityEdits.test.ts # and 41459, the seeds that found #9637 Tests 1 passed (1) $ pnpm run ci-check Found 0 errors ``` `TextEntityEdits.test.ts` also passes with `TEXT_ENTITY_SEEDS=25000`. The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts`, which compares the JSON, selection, mutations and keys after 300 random editing sessions with a copy of the helper before #9483. `packages/lexical-y/src/__tests__/unit/EntityUndoSpread.test.ts` (new, `test.fails`, #9716): two peers; a comment removed by one peer and both peers' undos leave the comment on every block after it. A `@lexical/y` bug (undo restores a mark opener without its closer), on `main` too with this test; this change reaches it more often because entities keep their comments. Pinned so it flips when #9716 is fixed. `registerLexicalTextEntityJoin.test.ts` (new, 11 tests): #9704's repro (a list item starting with a commented hashtag joined onto italic text), the same join by backspace and by forward delete, a block before that ends with a tab (all with NodeState), a hashtag with `MarkStateExtension` comment ids joined onto italic text, two hashtags turned into text before italic text with NodeState (the text keeps its format, NodeState and the selection), typing before a hashtag that a segmented and a token entity follow (settles, with a bounded number of `getMatch` calls), and a hashtag after text with 7 and 8 earlier matches (the most `isEntityAfter` checks) that does not grow over the commented italic text after it, while a later hashtag in that text is still made. With #9686 alone the first three lose the NodeState and the `MarkStateExtension` one loses its comment ids; with #9712 alone the hashtag stays a hashtag, as on `main`; here it becomes text and keeps them. ``` $ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityJoin.test.ts Tests 11 passed (11) ```
There was a problem hiding this comment.
Thanks, this looks good. Entities now keep their NodeState when they turn back into text, and plain text's NodeState carries into the entity made from it, which fixes #9703. The new tests fail on main and pass on this branch. Everything below is optional and non-blocking.
Optional suggestions
- A hashtag demoted with NodeState doesn't come back when the text before it is restored (
packages/lexical-text/src/registerLexicalTextEntity.ts, around line 494). Start withone+#tag(NodeStatec1) +more. Typingxbefore the hashtag correctly givesone x·#tag(text,c1). Removing thexagain leavesone #tag morewith#tagstill plain text: the transform ofonereturns whennextMatch.start !== match.startand never dirties the next sibling.mainhas the same gap next to text in another format (a bold hashtag behaves this way), and the description calls out the trade-off, so a follow-up is fine. Marking the next sibling dirty when the combined match starts where the current text ends would let its own transform re-parse it. - A kept token or segmented entity is remade on every edit after it (
$wasTurnedIntoText, line 333). Each update that dirties the following text demotes and re-creates the entity twice before keeping it, so the entity gets a new key on every keystroke. Mutation listeners seedestroyedandcreated, and three characters typed after it become three undo steps. Onmainthe same layouts throw the endless-transform error, so this is already an improvement. Skipping the demotion when it cannot change the parse (the match ends exactly at the entity and the next text would not merge) would keep the key. - Duplicated NodeState helpers.
$carryNodeStateinregisterLexicalTextEntity.ts(line 43) repeats the one added topackages/lexical/src/LexicalNodeState.ts(line 1320), andhaveEquivalentNodeStatemirrorsnodeStatesAreEquivalent. Exporting the core helper as anINTERNAL_symbol, asINTERNAL_$recordTextRetypeis, would leave one definition. - Test plan Before block. It reports
22 failed | 3 passed (25), but the two new@lexical/textfiles hold 26 tests (onmain22 fail and 4 pass), and the three newLexicalSelection.test.tstests have no pasted Before output. AGENTS.md (Commits and Pull Requests) asks for the real Before and After output of the command that was run.
| * holds keeps its value (when the text is made an entity: state that | ||
| * `createNode` gave it). | ||
| */ | ||
| function $carryNodeState(from: TextNode, to: TextNode): void { |
There was a problem hiding this comment.
Optional, non-blocking: this repeats the $carryNodeState added to packages/lexical/src/LexicalNodeState.ts. Exporting that one as an INTERNAL_ symbol, as INTERNAL_$recordTextRetype is, would leave one definition to maintain.
| // Whether a token or segmented entity with the same parent, place and text | ||
| // as `entity` was turned back into text as many times as it may be in one | ||
| // update; counts this time if not. | ||
| const $wasTurnedIntoText = (entity: TextNode): boolean => { |
There was a problem hiding this comment.
Optional, non-blocking: in every update that dirties the text after a kept token or segmented entity, this settle demotes and re-creates the entity twice before keeping it, so the entity gets a new key on every keystroke. Mutation listeners see destroyed and created, and typing after it is one undo step per character. main throws the endless-transform error in these layouts, so this is already an improvement. Skipping the demotion when it cannot change the parse (the match ends exactly at the entity and the next text would not merge) would keep the key.
Description
registerLexicalTextEntityturns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its#, joining blocks), with$replaceWithSimpleText. That built the newTextNodefrom the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.What carries, and why:
splitTextalready does for the pieces it cuts, and what the code highlighters do since [lexical-code-prism][lexical-code-shiki][lexical-mark][lexical-website][lexical-y] Bug Fix: Keep comment ids and NodeState when a block change moves or re-creates text #9503 when a code token becomes text. A key that the plain text type declares with a differentStateConfigis parsed with the text type's config. Format, style and detail carry as before.createNodedid not set (its state wins), with two exceptions, both characters that were just inserted into a node and so only have a copy of its NodeState: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag), and an entity made only of text inserted at the start or the end of a node that existed in this update, when no entity was turned into text in that block in the update (pasting#tagright after commented text: the hashtag does not join the comment, as onmainand asMarkStateExtension's rule for text inserted at a comment's edge says).splitTextgives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.mainthis already looped whenever that text had NodeState, or another format). So, as in [lexical-text][lexical-playground] Bug Fix: Text entity transforms settle between text nodes of other formats #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by:StateConfig.isEqual, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.TextNodebuilt from its text, format and style, the same way$replaceWithSimpleTextdid, and dropped its NodeState the same way (and its detail, such asunmergeable, which$replaceWithSimpleTextkept): typing inside it (outside composition,LexicalSelection) and removing a range that starts or ends inside it (LexicalCaretUtils). Both now keep them (detail, and NodeState with$carryNodeState, internal tolexical, with the same rules as above; keys the new node already holds, such as state a registeredTextNodereplacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here.Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.
Callers in this repo, all through
registerLexicalTextEntity:@lexical/hashtag(registerLexicalHashtag,HashtagExtension,LexicalHashtagPlugin),@lexical/reactuseLexicalTextEntity, and the playground'sKeywordsExtension. Mentions (typeahead) and@lexical/linkautolink have their own transforms and do not share this path.Fixes #9703
Test plan
.changeset/lexical-text-entity-node-state.md(lexicaland@lexical/text, patch,[fix]).MarkStateExtension.test.ts: a hashtag pasted or inserted at the edge of a comment (after it, and before it) is not added to it, withMarkStateExtensionand withMarkNode(as onmain).LexicalSelection.test.ts(3 tests, each fails onmain): typing inside a segmented node keeps its NodeState and detail; a registeredTextNodereplacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState and detail of what is left.registerLexicalTextEntityNodeState.test.ts(new, 24 tests): #9703's repro; deleting a hashtag's#; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState,createNode's state winning, text inserted after a hashtag not taking the copy, and a hashtag made only of inserted text not taking it; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's$config(nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops onmain; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.registerLexicalTextEntityNodeStateProperty.test.ts(new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (TEXT_ENTITY_NODE_STATE_SEEDS).Before (main):
After:
The existing
@lexical/texttests pass unchanged, includingregisterLexicalTextEntityEquivalence.test.ts(JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), andMarkStateExtension's hashtag tests (text inserted after a marked hashtag is not marked).A differential against
mainover random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps wheremaindrops it; no text is lost; token and segmented entities that loop onmainsettle. Where NodeState now keeps text apart, the entities can differ frommain, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layoutmainmerged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.