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:
Alessio Gravili
2025-09-29 13:07:34 -07:00
committed by GitHub
parent 41aa201f7b
commit 6a2e8149f1
2 changed files with 56 additions and 3 deletions

View File

@@ -1,6 +1,5 @@
'use client'
import type { EditorState, SerializedEditorState } from 'lexical'
import type { Validate } from 'payload'
import {
FieldDescription,
@@ -12,6 +11,8 @@ import {
useField,
} from '@payloadcms/ui'
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 { ErrorBoundary } from 'react-error-boundary'
@@ -140,10 +141,13 @@ const RichTextComponent: React.FC<
const handleInitialValueChange = useEffectEvent(
(initialValue: SerializedEditorState | undefined) => {
// 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 (
prevValueRef.current !== value &&
JSON.stringify(prevValueRef.current) !== JSON.stringify(value)
!dequal(JSON.parse(JSON.stringify(prevValueRef.current)), value)
) {
prevInitialValueRef.current = initialValue
prevValueRef.current = value

View File

@@ -8,6 +8,7 @@ import type { Config } from '../../../payload-types.js'
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
import { assertNetworkRequests } from '../../../../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../../helpers/reInitializeDB.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
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')
})
})
})