Skip to content

Commit 90fb9e1

Browse files
fix(richtext-lexical): preserve link drawer form state (#17586)
1. Opening a nested upload document drawer can trigger Lexical selection updates while the Edit Link drawer contains unsaved custom-field data. 2. The link editor could respond by either clearing that data when the selection became unavailable or rehydrating the drawer from the saved link node while it remained open. 3. Parent autosave replaces the parent form-state object. The fields drawer treated that object change as a fresh initialization trigger, rebuilt its form from the original link-node data, unmounted the nested document drawer, and discarded the unsaved Asset Link Block. Preserves `stateData` and `selectedNodes` while the Edit Link drawer remains open. The fields drawer now reads the latest `parentDocumentFields` when it genuinely initializes without reinitializing on parent autosave updates. End-to-end coverage reproduces the published-document autosave sequence, verifies the asset drawer remains open with its unsaved block values, and finalizes the link to confirm the selected Lexical text becomes the expected anchor. <!-- e2e-pr-media:start --> ### Before <!-- - Steps: 1. Open the persisted `Published asset type` edit view. 2. Type `hyperlink` into Description, select it, and open the Lexical inline link menu. 3. Fill the link URL, add Hyperlink, choose Asset Link Block, and fill its Label. 4. Choose the existing `client-asset.jpg` document and click its edit icon. 5. Pause on the resulting parent Edit Link drawer. - Final visible proof: - Show that the nested document drawer is gone and the Edit Link drawer has returned to an empty Hyperlink field with only “Add Hyperlink.” (Incorrect: opening the selected asset should keep the document drawer open and should not erase the Asset Link Block.) - Required view(s) and why: the persisted Asset Type edit view plus its nested drawers show the complete selection-to-edit transition and the parent form-state loss without unrelated list or card views. --> https://github.com/user-attachments/assets/dace3bde-1919-4921-bcac-af19b12906e6 ### After <!-- - Steps: 1. Open the same persisted `Published asset type` edit view. 2. Type `hyperlink` into Description, select it, and open the Lexical inline link menu. 3. Fill the same link URL, add Hyperlink, choose Asset Link Block, and fill its Label. 4. Choose the same existing `client-asset.jpg` document and click its edit icon. 5. Show the open document drawer, close it, and pause on the retained Asset Link Block in the parent Edit Link drawer. - Final visible proof: - Show the nested document drawer open with `Client asset document`, then show the parent Asset Link Block still containing label `Client asset` and asset `client-asset.jpg`. (Correct: the asset drawer stays open and all unsaved parent link data remains intact.) - Required view(s) and why: the same persisted Asset Type edit view and nested drawer stack prove both halves of the fix—stable nested-drawer lifecycle and preserved parent form data. --> https://github.com/user-attachments/assets/6ef5d703-d1e6-4484-979c-05c9cfbe2429 <!-- e2e-pr-media:end -->
1 parent 9e2c11e commit 90fb9e1

7 files changed

Lines changed: 289 additions & 42 deletions

File tree

packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
useConfig,
1515
useEditDepth,
1616
useLocale,
17+
useModal,
1718
useTranslation,
1819
} from '@payloadcms/ui'
1920
import { requests } from '@payloadcms/ui/shared'
@@ -86,6 +87,9 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
8687
depth: editDepth,
8788
})
8889

90+
const { modalState } = useModal()
91+
const isDrawerOpenRef = useRef(false)
92+
isDrawerOpenRef.current = Boolean(modalState[drawerSlug]?.isOpen)
8993
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
9094

9195
const setNotLink = useCallback(() => {
@@ -95,11 +99,15 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
9599
editorRef.current.style.transform = 'translate(-10000px, -10000px)'
96100
}
97101
setIsAutoLink(false)
98-
setLinkUrl(null)
99-
setLinkLabel(null)
100-
setSelectedNodes([])
101-
setStateData(undefined)
102-
}, [setIsLink, setLinkUrl, setLinkLabel, setSelectedNodes])
102+
// Nested fields can temporarily move selection away from the editor. Keep the backing form
103+
// state until the drawer closes so those interactions cannot reset or unmount its fields.
104+
if (!isDrawerOpenRef.current) {
105+
setLinkUrl(null)
106+
setLinkLabel(null)
107+
setSelectedNodes([])
108+
setStateData(undefined)
109+
}
110+
}, [])
103111

104112
const $updateLinkEditor = useCallback(() => {
105113
const selection = $getSelection()
@@ -212,9 +220,11 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
212220
}
213221
}
214222

215-
setStateData(data)
216223
setIsLink(true)
217-
setSelectedNodes(selection ? selection?.getNodes() : [])
224+
if (!isDrawerOpenRef.current) {
225+
setStateData(data)
226+
setSelectedNodes(selection ? selection?.getNodes() : [])
227+
}
218228

219229
if ($isAutoLinkNode(focusLinkParent)) {
220230
setIsAutoLink(true)

packages/richtext-lexical/src/utilities/fieldsDrawer/DrawerContent.tsx

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
RenderFields,
99
useDocumentForm,
1010
useDocumentInfo,
11+
useEffectEvent,
1112
useServerFunctions,
1213
useTranslation,
1314
} from '@payloadcms/ui'
@@ -50,34 +51,36 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
5051

5152
const fields: any = fieldMapOverride ?? featureClientSchemaMap[featureKey]?.[schemaFieldsPath] // Field Schema
5253

53-
useEffect(() => {
54-
const controller = new AbortController()
54+
// Parent form state changes after autosave. Read its latest value when initializing without
55+
// reinitializing this drawer and discarding its unsaved form state after every parent update.
56+
const getInitialState = useEffectEvent(async (controller: AbortController) => {
57+
const { state } = await getFormState({
58+
id,
59+
collectionSlug,
60+
data: data ?? {},
61+
docPermissions: {
62+
fields: true,
63+
},
64+
docPreferences: await getDocPreferences(),
65+
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields, {
66+
excludeFiles: true,
67+
}),
68+
globalSlug,
69+
initialBlockData: data,
70+
operation: 'update',
71+
readOnly: !isEditable,
72+
renderAllFields: true,
73+
schemaPath: schemaFieldsPath,
74+
signal: controller.signal,
75+
})
5576

56-
const awaitInitialState = async () => {
57-
const { state } = await getFormState({
58-
id,
59-
collectionSlug,
60-
data: data ?? {},
61-
docPermissions: {
62-
fields: true,
63-
},
64-
docPreferences: await getDocPreferences(),
65-
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields, {
66-
excludeFiles: true,
67-
}),
68-
globalSlug,
69-
initialBlockData: data,
70-
operation: 'update',
71-
readOnly: !isEditable,
72-
renderAllFields: true,
73-
schemaPath: schemaFieldsPath,
74-
signal: controller.signal,
75-
})
77+
setInitialState(state)
78+
})
7679

77-
setInitialState(state)
78-
}
80+
useEffect(() => {
81+
const controller = new AbortController()
7982

80-
void awaitInitialState()
83+
void getInitialState(controller)
8184

8285
return () => {
8386
abortAndIgnore(controller)
@@ -91,7 +94,6 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
9194
isEditable,
9295
globalSlug,
9396
getDocPreferences,
94-
parentDocumentFields,
9597
])
9698

9799
const onChange = useCallback(

test/lexical/baseConfig.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import { LexicalCustomCell } from './collections/LexicalCustomCell/index.js'
2020
import { LexicalHeadingFeature } from './collections/LexicalHeadingFeature/index.js'
2121
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
2222
import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js'
23-
import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
23+
import {
24+
LexicalLinkFeature,
25+
LexicalLinkFeatureAutosave,
26+
} from './collections/LexicalLinkFeature/index.js'
2427
import { LexicalListsFeature } from './collections/LexicalListsFeature/index.js'
2528
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
2629
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
@@ -57,6 +60,7 @@ export const baseConfig: Partial<Config> = {
5760
LexicalFullyFeatured,
5861
LexicalAutosave,
5962
LexicalLinkFeature,
63+
LexicalLinkFeatureAutosave,
6064
LexicalListsFeature,
6165
LexicalHeadingFeature,
6266
LexicalJSXConverter,

test/lexical/collections/LexicalLinkFeature/e2e.spec.ts

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { expect, test } from '@playwright/test'
2-
import { lexicalLinkFeatureSlug } from 'lexical/slugs.js'
2+
import {
3+
lexicalLinkFeatureAutosaveSlug,
4+
lexicalLinkFeatureSlug,
5+
uploadsSlug,
6+
} from 'lexical/slugs.js'
37
import path from 'path'
48
import { fileURLToPath } from 'url'
59

610
import { ensureCompilationIsDone, waitForFormReady } from '../../../__helpers/e2e/helpers.js'
11+
import { waitForAutoSaveToRunAndComplete } from '../../../__helpers/e2e/waitForAutoSaveToRunAndComplete.js'
712
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
813
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
914
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
@@ -12,7 +17,7 @@ const filename = fileURLToPath(import.meta.url)
1217
const currentFolder = path.dirname(filename)
1318
const dirname = path.resolve(currentFolder, '../../')
1419

15-
const { beforeAll, beforeEach, describe } = test
20+
const { afterEach, beforeAll, beforeEach, describe } = test
1621

1722
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
1823
// PLEASE do not reset the database or perform any operations that modify it in this file.
@@ -25,6 +30,8 @@ const { serverURL } = await initPayloadE2ENoConfig({
2530
})
2631

2732
describe('Lexical Link Feature', () => {
33+
const createdAutosaveDocIDs: Array<number | string> = []
34+
2835
beforeAll(async ({ browser }, testInfo) => {
2936
testInfo.setTimeout(TEST_TIMEOUT_LONG)
3037
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
@@ -39,6 +46,14 @@ describe('Lexical Link Feature', () => {
3946
await lexical.editor.first().focus()
4047
})
4148

49+
afterEach(async ({ page }) => {
50+
for (const id of createdAutosaveDocIDs) {
51+
await page.request.delete(`${serverURL}/api/${lexicalLinkFeatureAutosaveSlug}/${id}`)
52+
}
53+
54+
createdAutosaveDocIDs.length = 0
55+
})
56+
4257
test('can add new custom fields in link feature modal', async ({ page }) => {
4358
const lexical = new LexicalHelpers(page)
4459

@@ -342,4 +357,128 @@ describe('Lexical Link Feature', () => {
342357
})
343358
.toBe(true)
344359
})
360+
361+
test('should preserve link form state while editing a nested upload document', async ({
362+
page,
363+
}) => {
364+
const lexical = new LexicalHelpers(page)
365+
366+
await lexical.editor.fill('custom link')
367+
await lexical.editor.selectText()
368+
await lexical.inlineToolbar.locator('.toolbar-popup__button-link').click()
369+
370+
const linkDrawer = page.locator('.lexical-link-edit-drawer')
371+
const assetLabelField = linkDrawer.locator('#field-hyperlink__0__label')
372+
const finalizedURL = 'https://example.com/finalized'
373+
374+
await linkDrawer.locator('#field-url').fill(finalizedURL)
375+
await linkDrawer.getByRole('button', { name: 'Add Hyperlink' }).click()
376+
await page.locator('.blocks-drawer__block').filter({ hasText: 'Asset Link Block' }).click()
377+
await assetLabelField.fill('Client asset')
378+
await linkDrawer.getByRole('button', { name: 'Choose from existing' }).click()
379+
380+
const uploadListDrawer = page.locator('dialog[id^=list-drawer_2_]').first()
381+
382+
await uploadListDrawer
383+
.getByText(/payload(?:-\d+)?\.jpg/)
384+
.first()
385+
.click()
386+
387+
const selectedFilename = linkDrawer.locator('.upload-relationship-details__filename')
388+
389+
await expect(selectedFilename).toHaveText(/payload(?:-\d+)?\.jpg/)
390+
391+
await linkDrawer.locator('.upload-relationship-details__edit').click()
392+
393+
const uploadDocumentDrawer = page.locator(`dialog[id^=doc-drawer_${uploadsSlug}_2_]`).first()
394+
395+
await expect(uploadDocumentDrawer).toBeVisible()
396+
await expect(uploadDocumentDrawer.locator('#field-text')).toBeVisible()
397+
await expect(linkDrawer.locator('.blocks-field__row')).toHaveCount(1)
398+
await expect(assetLabelField).toHaveValue('Client asset')
399+
await expect(selectedFilename).toHaveText(/payload(?:-\d+)?\.jpg/)
400+
401+
await uploadDocumentDrawer.getByRole('button', { name: 'Close' }).first().click()
402+
await expect(uploadDocumentDrawer).toBeHidden()
403+
await lexical.save('drawer')
404+
405+
const createdLink = lexical.editor.getByRole('link', { name: 'custom link' })
406+
407+
await expect(createdLink).toHaveAttribute('href', finalizedURL)
408+
await createdLink.dispatchEvent('mouseover')
409+
await page.locator('.link-edit').click()
410+
await expect(linkDrawer).toBeVisible()
411+
await expect(linkDrawer.locator('.blocks-field__row')).toHaveCount(1)
412+
await expect(assetLabelField).toHaveValue('Client asset')
413+
await expect(selectedFilename).toHaveText(/payload(?:-\d+)?\.jpg/)
414+
})
415+
416+
test('should preserve an open nested upload drawer and unsaved link fields through parent autosave', async ({
417+
page,
418+
}) => {
419+
const createResponse = await page.request.post(
420+
`${serverURL}/api/${lexicalLinkFeatureAutosaveSlug}`,
421+
{
422+
data: {
423+
_status: 'published',
424+
},
425+
},
426+
)
427+
428+
expect(createResponse.ok()).toBe(true)
429+
430+
const {
431+
doc: { id },
432+
} = await createResponse.json()
433+
const url = new AdminUrlUtil(serverURL, lexicalLinkFeatureAutosaveSlug)
434+
435+
createdAutosaveDocIDs.push(id)
436+
await page.goto(url.edit(id))
437+
await waitForFormReady(page)
438+
439+
const lexical = new LexicalHelpers(page)
440+
441+
await lexical.editor.fill('custom link')
442+
await lexical.editor.selectText()
443+
await lexical.inlineToolbar.locator('.toolbar-popup__button-link').click()
444+
445+
const linkDrawer = page.locator('.lexical-link-edit-drawer')
446+
const assetLabelField = linkDrawer.locator('#field-hyperlink__0__label')
447+
const finalizedURL = 'https://example.com/autosave-finalized'
448+
449+
await linkDrawer.locator('#field-url').fill(finalizedURL)
450+
await linkDrawer.getByRole('button', { name: 'Add Hyperlink' }).click()
451+
await page.locator('.blocks-drawer__block').filter({ hasText: 'Asset Link Block' }).click()
452+
await assetLabelField.fill('Client asset')
453+
await linkDrawer.getByRole('button', { name: 'Choose from existing' }).click()
454+
455+
const uploadListDrawer = page.locator('dialog[id^=list-drawer_2_]').first()
456+
457+
await uploadListDrawer
458+
.getByText(/payload(?:-\d+)?\.jpg/)
459+
.first()
460+
.click()
461+
462+
const selectedFilename = linkDrawer.locator('.upload-relationship-details__filename')
463+
464+
await expect(selectedFilename).toHaveText(/payload(?:-\d+)?\.jpg/)
465+
await linkDrawer.locator('.upload-relationship-details__edit').click()
466+
467+
const uploadDocumentDrawer = page.locator(`dialog[id^=doc-drawer_${uploadsSlug}_2_]`).first()
468+
469+
await expect(uploadDocumentDrawer).toBeVisible()
470+
await waitForAutoSaveToRunAndComplete(page)
471+
await expect(uploadDocumentDrawer).toBeVisible()
472+
await expect(linkDrawer.locator('.blocks-field__row')).toHaveCount(1)
473+
await expect(assetLabelField).toHaveValue('Client asset')
474+
await expect(selectedFilename).toHaveText(/payload(?:-\d+)?\.jpg/)
475+
476+
await uploadDocumentDrawer.getByRole('button', { name: 'Close' }).first().click()
477+
await expect(uploadDocumentDrawer).toBeHidden()
478+
await lexical.save('drawer')
479+
480+
const createdLink = lexical.editor.getByRole('link', { name: 'custom link' })
481+
482+
await expect(createdLink).toHaveAttribute('href', finalizedURL)
483+
})
345484
})

0 commit comments

Comments
 (0)