From 6a2e8149f169292b3d6948e6075cb966813fc653 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 29 Sep 2025 13:07:34 -0700 Subject: [PATCH] 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 --- packages/richtext-lexical/src/field/Field.tsx | 10 ++-- .../_LexicalFullyFeatured/db/e2e.spec.ts | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index e87ea5d9a..471abb338 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -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 diff --git a/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts index 3b84db23c..197b698fc 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts @@ -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') + }) }) })