Skip to content

Commit 931a349

Browse files
authored
fix(richtext-lexical): cursor kicked out of nested richtext while typing in a block (#16490)
Backports #16478
1 parent caf9150 commit 931a349

7 files changed

Lines changed: 294 additions & 111 deletions

File tree

packages/richtext-lexical/src/features/blocks/client/component/index.tsx

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
useTranslation,
2525
} from '@payloadcms/ui'
2626
import { abortAndIgnore } from '@payloadcms/ui/shared'
27-
import { $getNodeByKey } from 'lexical'
27+
import { $getNodeByKey, SKIP_DOM_SELECTION_TAG } from 'lexical'
2828
import {
2929
type BlocksFieldClient,
3030
type ClientBlock,
@@ -269,15 +269,20 @@ export const BlockComponent: React.FC<BlockComponentProps> = (props) => {
269269
) as BlockFields
270270

271271
// Things like default values may come back from the server => update the node with the new data
272-
editor.update(() => {
273-
const node = $getNodeByKey(nodeKey)
274-
if (node && $isBlockNode(node)) {
275-
const newData = newFormStateData
276-
newData.blockType = blockType
277-
278-
node.setFields(newData, true)
279-
}
280-
})
272+
editor.update(
273+
() => {
274+
const node = $getNodeByKey(nodeKey)
275+
if (node && $isBlockNode(node)) {
276+
const newData = newFormStateData
277+
newData.blockType = blockType
278+
279+
node.setFields(newData, true)
280+
}
281+
},
282+
// Without this, the outer editor's reconciler resets DOM selection
283+
// back into its own root, kicking focus out of any nested richText.
284+
{ tag: SKIP_DOM_SELECTION_TAG },
285+
)
281286

282287
setInitialState(state)
283288
if (!CustomLabelFromProps) {
@@ -377,14 +382,19 @@ export const BlockComponent: React.FC<BlockComponentProps> = (props) => {
377382
) as BlockFields
378383

379384
setTimeout(() => {
380-
editor.update(() => {
381-
const node = $getNodeByKey(nodeKey)
382-
if (node && $isBlockNode(node)) {
383-
const newData = newFormStateData
384-
newData.blockType = blockType
385-
node.setFields(newData, true)
386-
}
387-
})
385+
editor.update(
386+
() => {
387+
const node = $getNodeByKey(nodeKey)
388+
if (node && $isBlockNode(node)) {
389+
const newData = newFormStateData
390+
newData.blockType = blockType
391+
node.setFields(newData, true)
392+
}
393+
},
394+
// Without this, the outer editor's reconciler resets DOM selection
395+
// back into its own root, kicking focus out of any nested richText.
396+
{ tag: SKIP_DOM_SELECTION_TAG },
397+
)
388398
}, 0)
389399

390400
if (submit) {
@@ -677,12 +687,17 @@ export const BlockComponent: React.FC<BlockComponentProps> = (props) => {
677687
onSubmit={(formState, newData) => {
678688
// This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
679689
newData.blockType = blockType
680-
editor.update(() => {
681-
const node = $getNodeByKey(nodeKey)
682-
if (node && $isBlockNode(node)) {
683-
node.setFields(newData as BlockFields, true)
684-
}
685-
})
690+
editor.update(
691+
() => {
692+
const node = $getNodeByKey(nodeKey)
693+
if (node && $isBlockNode(node)) {
694+
node.setFields(newData as BlockFields, true)
695+
}
696+
},
697+
// Without this, the outer editor's reconciler resets DOM selection
698+
// back into its own root, kicking focus out of any nested richText.
699+
{ tag: SKIP_DOM_SELECTION_TAG },
700+
)
686701
toggleDrawer()
687702
}}
688703
submitted={submitted}

packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
useTranslation,
2323
} from '@payloadcms/ui'
2424
import { abortAndIgnore } from '@payloadcms/ui/shared'
25-
import { $getNodeByKey } from 'lexical'
25+
import { $getNodeByKey, SKIP_DOM_SELECTION_TAG } from 'lexical'
2626

2727
import './index.scss'
2828

@@ -264,15 +264,20 @@ export const InlineBlockComponent: React.FC<InlineBlockComponentProps<InlineBloc
264264
) as InlineBlockFields
265265

266266
// Things like default values may come back from the server => update the node with the new data
267-
editor.update(() => {
268-
const node = $getNodeByKey(nodeKey)
269-
if (node && $isInlineBlockNode(node)) {
270-
const newData = newFormStateData
271-
newData.blockType = formData.blockType
272-
273-
node.setFields(newData, true)
274-
}
275-
})
267+
editor.update(
268+
() => {
269+
const node = $getNodeByKey(nodeKey)
270+
if (node && $isInlineBlockNode(node)) {
271+
const newData = newFormStateData
272+
newData.blockType = formData.blockType
273+
274+
node.setFields(newData, true)
275+
}
276+
},
277+
// Without this, the outer editor's reconciler resets DOM selection
278+
// back into its own root, kicking focus out of any nested richText.
279+
{ tag: SKIP_DOM_SELECTION_TAG },
280+
)
276281

277282
setInitialState(state)
278283
if (!CustomLabelFromProps) {
@@ -392,12 +397,17 @@ export const InlineBlockComponent: React.FC<InlineBlockComponentProps<InlineBloc
392397
(formState: FormState, newData: Data) => {
393398
newData.blockType = formData.blockType
394399

395-
editor.update(() => {
396-
const node = $getNodeByKey(nodeKey)
397-
if (node && $isInlineBlockNode(node)) {
398-
node.setFields(newData as InlineBlockFields, true)
399-
}
400-
})
400+
editor.update(
401+
() => {
402+
const node = $getNodeByKey(nodeKey)
403+
if (node && $isInlineBlockNode(node)) {
404+
node.setFields(newData as InlineBlockFields, true)
405+
}
406+
},
407+
// Without this, the outer editor's reconciler resets DOM selection
408+
// back into its own root, kicking focus out of any nested richText.
409+
{ tag: SKIP_DOM_SELECTION_TAG },
410+
)
401411
},
402412
[editor, nodeKey, formData],
403413
)

test/lexical/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from './collections/Lexical/index.js'
1515
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
1616
import { LexicalAutosave } from './collections/LexicalAutosave/index.js'
17+
import { LexicalAutosaveBlock } from './collections/LexicalAutosaveBlock/index.js'
1718
import { LexicalBenchmark } from './collections/LexicalBenchmark/index.js'
1819
import { LexicalCustomCell } from './collections/LexicalCustomCell/index.js'
1920
import { LexicalHeadingFeature } from './collections/LexicalHeadingFeature/index.js'
@@ -73,6 +74,7 @@ export const baseConfig: Partial<Config> = {
7374
LexicalLocalizedFields,
7475
LexicalObjectReferenceBugCollection,
7576
LexicalInBlock,
77+
LexicalAutosaveBlock,
7678
LexicalAccessControl,
7779
LexicalRelationshipsFields,
7880
LexicalSlugFieldNameCollision,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect, test } from '@playwright/test'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
import type { PayloadTestSDK } from '../../../__helpers/shared/sdk/index.js'
6+
import type { Config } from '../../payload-types.js'
7+
8+
import { ensureCompilationIsDone } from '../../../__helpers/e2e/helpers.js'
9+
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
10+
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
11+
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
12+
import { lexicalAutosaveBlockSlug } from '../../slugs.js'
13+
import { LexicalHelpers } from '../utils.js'
14+
15+
const filename = fileURLToPath(import.meta.url)
16+
const currentFolder = path.dirname(filename)
17+
const dirname = path.resolve(currentFolder, '../../')
18+
19+
let payload: PayloadTestSDK<Config>
20+
let serverURL: string
21+
22+
const { beforeAll, beforeEach, describe } = test
23+
24+
// Repro: when the parent collection has autosave enabled, every autosave
25+
// re-renders the outer richText, which unmounts/remounts the block decorator
26+
// containing the nested richText. The nested editor loses focus and any
27+
// characters typed after the autosave fires are dropped on the floor.
28+
describe('Lexical: nested richText loses focus on parent autosave', () => {
29+
let lexical: LexicalHelpers
30+
31+
beforeAll(async ({ browser }, testInfo) => {
32+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
33+
process.env.SEED_IN_CONFIG_ONINIT = 'false'
34+
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
35+
36+
const page = await browser.newPage()
37+
await ensureCompilationIsDone({ page, serverURL })
38+
await page.close()
39+
})
40+
41+
beforeEach(async ({ page }) => {
42+
const url = new AdminUrlUtil(serverURL, lexicalAutosaveBlockSlug)
43+
lexical = new LexicalHelpers(page)
44+
await page.goto(url.create)
45+
await lexical.editor.first().focus()
46+
})
47+
48+
test('typing in a nested richText keeps focus and retains characters across autosaves', async ({
49+
page,
50+
}) => {
51+
await lexical.slashCommand('blockwithrichtext', true, 'Block With Rich Text')
52+
53+
await expect(lexical.editor).toHaveCount(2)
54+
55+
const nestedEditor = lexical.editor.nth(1)
56+
await nestedEditor.click()
57+
58+
// Per-key delay > autosave interval (100ms) so autosave is guaranteed to
59+
// fire between keystrokes. That is the timing that triggers the bug.
60+
const typed = 'abcdefghij'
61+
await page.keyboard.type(typed, { delay: 300 })
62+
63+
// Allow any in-flight autosave to settle so the focus state has converged.
64+
await page.waitForTimeout(500)
65+
66+
await expect(nestedEditor).toHaveText(typed)
67+
68+
const isNestedFocused = await page.evaluate(() => {
69+
const editors = document.querySelectorAll('[data-lexical-editor="true"]')
70+
return document.activeElement === editors[1]
71+
})
72+
expect(isNestedFocused).toBe(true)
73+
})
74+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
4+
5+
import { lexicalAutosaveBlockSlug } from '../../slugs.js'
6+
7+
export const LexicalAutosaveBlock: CollectionConfig = {
8+
slug: lexicalAutosaveBlockSlug,
9+
versions: {
10+
drafts: {
11+
autosave: {
12+
interval: 100,
13+
},
14+
},
15+
},
16+
fields: [
17+
{
18+
name: 'content',
19+
type: 'richText',
20+
editor: lexicalEditor({
21+
features: [
22+
BlocksFeature({
23+
blocks: [
24+
{
25+
slug: 'blockWithRichText',
26+
fields: [
27+
{
28+
name: 'nestedRichText',
29+
type: 'richText',
30+
editor: lexicalEditor(),
31+
},
32+
],
33+
},
34+
],
35+
}),
36+
],
37+
}),
38+
},
39+
],
40+
}

0 commit comments

Comments
 (0)