fix(richtext-lexical): editor re-mounting on save due to json key order not being preserved in postgres (#13962)
Fixes https://github.com/payloadcms/payload/issues/13904 When using Postgres, saving a document can cause the editor to re-mount. If the document includes a blocks field, this leads to the editor incorrectly resetting its value to the previous state. The issue occurs because the editor is re-mounted without recalculating the initial Lexical state. This re-mounting behavior is caused by how Postgres handles JSON storage: - With `jsonb` columns, unlike `json` columns, object key order is not guaranteed. - As a result, saving and reloading the Lexical editor state shifts key order, causing `JSON.stringify()` comparisons to fail. ## Solution To fix this, we now compare the incoming and previous editor state in a way that ignores key order. Specifically, we switched from `JSON.stringify()` to `dequal` for deep equality checks. ## Notes - This code only runs when external changes occur (e.g. after saving a document or when custom code modifies form fields). - Because this check is infrequent, performance impact is negligible. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211488746010087
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { EditorState, SerializedEditorState } from 'lexical'
|
import type { EditorState, SerializedEditorState } from 'lexical'
|
||||||
import type { Validate } from 'payload'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
@@ -12,6 +11,8 @@ import {
|
|||||||
useField,
|
useField,
|
||||||
} from '@payloadcms/ui'
|
} from '@payloadcms/ui'
|
||||||
import { mergeFieldStyles } from '@payloadcms/ui/shared'
|
import { mergeFieldStyles } from '@payloadcms/ui/shared'
|
||||||
|
import { dequal } from 'dequal/lite'
|
||||||
|
import { type Validate } from 'payload'
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { ErrorBoundary } from 'react-error-boundary'
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
|
|
||||||
@@ -140,10 +141,13 @@ const RichTextComponent: React.FC<
|
|||||||
const handleInitialValueChange = useEffectEvent(
|
const handleInitialValueChange = useEffectEvent(
|
||||||
(initialValue: SerializedEditorState | undefined) => {
|
(initialValue: SerializedEditorState | undefined) => {
|
||||||
// Object deep equality check here, as re-mounting the editor if
|
// Object deep equality check here, as re-mounting the editor if
|
||||||
// the new value is the same as the old one is not necessary
|
// the new value is the same as the old one is not necessary.
|
||||||
|
// In postgres, the order of keys in JSON objects is not guaranteed to be preserved,
|
||||||
|
// so we need to do a deep equality check here that does not care about key order => we use dequal.
|
||||||
|
// If we used JSON.stringify, the editor would re-mount every time you save the document, as the order of keys changes => change detected => re-mount.
|
||||||
if (
|
if (
|
||||||
prevValueRef.current !== value &&
|
prevValueRef.current !== value &&
|
||||||
JSON.stringify(prevValueRef.current) !== JSON.stringify(value)
|
!dequal(JSON.parse(JSON.stringify(prevValueRef.current)), value)
|
||||||
) {
|
) {
|
||||||
prevInitialValueRef.current = initialValue
|
prevInitialValueRef.current = initialValue
|
||||||
prevValueRef.current = value
|
prevValueRef.current = value
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Config } from '../../../payload-types.js'
|
|||||||
|
|
||||||
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
|
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
|
||||||
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
|
||||||
|
import { assertNetworkRequests } from '../../../../helpers/e2e/assertNetworkRequests.js'
|
||||||
import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { reInitializeDB } from '../../../../helpers/reInitializeDB.js'
|
import { reInitializeDB } from '../../../../helpers/reInitializeDB.js'
|
||||||
import { TEST_TIMEOUT_LONG } from '../../../../playwright.config.js'
|
import { TEST_TIMEOUT_LONG } from '../../../../playwright.config.js'
|
||||||
@@ -123,5 +124,53 @@ describe('Lexical Fully Featured - database', () => {
|
|||||||
// @ts-expect-error unsafe access is fine in tests
|
// @ts-expect-error unsafe access is fine in tests
|
||||||
expect(uploadNode.value?.filename).toBe('payload-1.jpg')
|
expect(uploadNode.value?.filename).toBe('payload-1.jpg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ensure block contents are not reset on save on both create and update', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await lexical.slashCommand('myblock')
|
||||||
|
await expect(lexical.editor.locator('.lexical-block')).toBeVisible()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test on create
|
||||||
|
*/
|
||||||
|
await assertNetworkRequests(
|
||||||
|
page,
|
||||||
|
`/admin/collections/${lexicalFullyFeaturedSlug}`,
|
||||||
|
async () => {
|
||||||
|
await lexical.editor.locator('#field-someText').first().fill('Testing 123')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minimumNumberOfRequests: 2,
|
||||||
|
allowedNumberOfRequests: 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Testing 123')
|
||||||
|
await saveDocAndAssert(page)
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Testing 123')
|
||||||
|
await page.reload()
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Testing 123')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test on update (this is where the issue appeared)
|
||||||
|
*/
|
||||||
|
await assertNetworkRequests(
|
||||||
|
page,
|
||||||
|
`/admin/collections/${lexicalFullyFeaturedSlug}`,
|
||||||
|
async () => {
|
||||||
|
await lexical.editor.locator('#field-someText').first().fill('Updated text')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minimumNumberOfRequests: 2,
|
||||||
|
allowedNumberOfRequests: 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Updated text')
|
||||||
|
await saveDocAndAssert(page)
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Updated text')
|
||||||
|
await page.reload()
|
||||||
|
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Updated text')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user