fix(richtext-lexical): recursively unwrap generic Slate nodes in Lexical migration converter (#13202)

## What?

The Slate to Lexical migration script assumes that the depth of Slate
nodes matches the depth of the Lexical schema, which isn't necessarily
true. This pull request fixes this assumption by first checking for
children and unwrapping the text nodes.

## Why?

During my migration, I ran into a lot of copy + pasted rich text with
list items with untyped nodes with `children`. The existing migration
script assumed that since list items can't have paragraphs, all untyped
nodes inside must be text nodes.

The result of the migration script was a lot of invalid text nodes with
`text: undefined` and all of the content in the `children` being
silently lost. Beyond the silent loss, the invalid text nodes caused the
Lexical editor to unmount with an error about accessing `0 of
undefined`, so those documents couldn't be edited.

This additionally makes the migration script more closely align with the
[recursive serialization logic recommendation from the Payload Slate
Rich Text
documentation](https://payloadcms.com/docs/rich-text/slate#generating-html).

## Visualization

### Slate

```txt
Slate rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━┳━ Generic (paragraph-like, untyped with children)
┋ ┋   ┣━━━ Text (untyped) `Hello `
┋ ┋   ┗━━━ Text (untyped) `World!
[...]
```

### Lexical Before PR

```txt
Lexical rich text content (invalid)
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┗━━━ Invalid text (assumed the generic node was text, stopped processing children, cannot restore lost text without a restoring backup with Slate and rerunning the script after this MR)
[...]
```

### Lexical After PR

```txt
Lexical rich text content
┣━┳━ Unordered list
┋ ┣━┳━ List item
┋ ┋ ┣━━━ Text `Hello `
┋ ┋ ┗━━━ Text `World!
[...]
```

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
Evelyn Hathaway
2025-07-30 06:16:18 -07:00
committed by GitHub
parent a22f27de1c
commit 227a20e94b
2 changed files with 23 additions and 3 deletions

View File

@@ -53,12 +53,23 @@ export function convertSlateNodesToLexical({
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))
// @ts-expect-error - vestiges of the migration to strict mode. Probably not important enough in this file to fix
return (
slateNodes.map((slateNode, i) => {
// Flatten in case we unwrap an array of child nodes
slateNodes.flatMap((slateNode, i) => {
if (!('type' in slateNode)) {
if (canContainParagraphs) {
// This is a paragraph node. They do not have a type property in Slate
return convertParagraphNode(converters, slateNode)
} else {
// Unwrap generic Slate nodes recursively since depth wasn't guaranteed by Slate, especially when copy + pasting rich text
// - If there are children and it can't be a paragraph in Lexical, assume that the generic node should be unwrapped until the text nodes, and only assume that its a text node when there are no more children
if (slateNode.children) {
return convertSlateNodesToLexical({
canContainParagraphs,
converters,
parentNodeType,
slateNodes: slateNode.children || [],
})
}
// This is a simple text node. canContainParagraphs may be false if this is nested inside a paragraph already, since paragraphs cannot contain paragraphs
return convertTextNode(slateNode)
}
@@ -113,7 +124,7 @@ export function convertTextNode(node: SlateNode): SerializedTextNode {
format: convertNodeToFormat(node),
mode: 'normal',
style: '',
text: node.text,
text: node.text ?? "",
version: 1,
}
}

View File

@@ -73,7 +73,16 @@ export function generateSlateRichText() {
{
children: [
{
text: "It's built with SlateJS",
// This node is untyped, because I want to test this scenario:
// https://github.com/payloadcms/payload/pull/13202
children: [
{
text: 'This editor is built ',
},
{
text: 'with SlateJS',
},
],
},
],
type: 'li',