Skip to content

Commit 82f5576

Browse files
jhb-devPatrikKozak
andauthored
fix(ui): rich text fields not read-only inside tabs on trashed and locked documents 3.x (#17791)
### What? Rich text fields nested inside a `tabs` field stayed editable on trashed documents (and on documents opened read-only after a document lock), while every other field on the same document was correctly disabled. ### Why? Rich text renders as a server component, so `RenderField` returns the pre-rendered RSC and discards the client-side `readOnly` prop that `RenderFields` threads down. Its read-only state comes solely from the `readOnly` argument passed into `buildFormState` by the document view (`readOnly: isTrashedDoc || isLocked`). Every branch of `addFieldStatePromise` forwards that value into its children — `array`, `blocks`, `group`, `row`/`collapsible`, `tab` — except the `tabs` branch, which omitted it. Form state for anything under a tabs field was therefore built with `readOnly: undefined`, `renderField` fell back to permissions, and the field was rendered with `readOnly: false`. Fields that aren't server-rendered were unaffected, because they receive `readOnly` through the client-side `RenderFields` prop chain — which is why this looked arbitrary in practice: on the same page, a rich text field in one block is disabled while one inside a tabbed block is editable. This is the same class of bug as #13579, which fixed rich text read-only for locked documents but only for fields that are not inside tabs. ### How? - Forward `readOnly` in the `tabs` branch of `addFieldStatePromise`. - The edit view's `onChange` never sent `readOnly` with subsequent form-state requests, so any field re-rendered by one of those requests came back editable on a trashed or locked document. It now sends `isTrashed || isReadOnlyForIncomingUser`, mirroring the initial server render. This one is hardening rather than a visible fix: `LexicalProvider` memoizes `initialConfig.editable` and keys the composer on it, so an editor mounted read-only won't flip back on its own — but the form state was wrong, and any other server-rendered custom `Field` that honours prop changes would be affected. Regression test added to `test/trash`: the collection gets a rich text field and a rich text field inside a `tabs` field, and the trashed-document edit view test asserts both render `contenteditable="false"` / `aria-readonly="true"`. Verified red before the fix and green after, on this branch and on `3.x`. v3 backport of #17790 — see #17789. --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
1 parent 61d3961 commit 82f5576

5 files changed

Lines changed: 71 additions & 0 deletions

File tree

‎packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
989989
permissions: parentPermissions,
990990
preferences,
991991
previousFormState,
992+
readOnly,
992993
renderAllFields,
993994
renderFieldFn,
994995
req,

‎packages/ui/src/views/Edit/index.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ export function DefaultEditView({
497497
!hasCheckedForStaleDataRef.current &&
498498
originalUpdatedAtRef.current &&
499499
operation === 'update' &&
500+
!isTrashed &&
500501
!autosaveEnabled
501502

502503
if (checkForStaleData) {
@@ -515,6 +516,7 @@ export function DefaultEditView({
515516
globalSlug,
516517
operation,
517518
originalUpdatedAt: checkForStaleData ? originalUpdatedAtRef.current : undefined,
519+
readOnly: isTrashed || isReadOnlyForIncomingUser,
518520
renderAllFields: false,
519521
returnLockStatus: isLockingEnabled,
520522
schemaPath: schemaPathSegments.join('.'),
@@ -562,6 +564,8 @@ export function DefaultEditView({
562564
schemaPathSegments,
563565
handleDocumentLocking,
564566
autosaveEnabled,
567+
isTrashed,
568+
isReadOnlyForIncomingUser,
565569
],
566570
)
567571

‎test/trash/collections/Posts/index.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ export const Posts: CollectionConfig = {
1919
type: 'text',
2020
localized: true,
2121
},
22+
{
23+
name: 'richText',
24+
type: 'richText',
25+
},
26+
{
27+
type: 'tabs',
28+
tabs: [
29+
{
30+
label: 'Tab',
31+
fields: [
32+
{
33+
name: 'richTextInTab',
34+
type: 'richText',
35+
},
36+
],
37+
},
38+
],
39+
},
2240
],
2341
versions: {
2442
drafts: true,

‎test/trash/e2e.spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,22 @@ describe('Trash', () => {
645645
await expect(statusBlock).toContainText('Previously Published')
646646
})
647647

648+
test('Should render rich text fields as read-only, including inside tabs', async ({
649+
page,
650+
}) => {
651+
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
652+
653+
for (const fieldPath of ['richText', 'richTextInTab']) {
654+
const editor = page.locator(
655+
`[data-field-path="${fieldPath}"] .ContentEditable__root[data-lexical-editor="true"]`,
656+
)
657+
658+
await expect(editor).toBeVisible()
659+
await expect(editor).toHaveAttribute('contenteditable', 'false')
660+
await expect(editor).toHaveAttribute('aria-readonly', 'true')
661+
}
662+
})
663+
648664
test('Should render Permanently Delete and Restore buttons in doc controls', async ({
649665
page,
650666
}) => {

‎test/trash/payload-types.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,36 @@ export interface Post {
144144
id: string;
145145
title: string;
146146
localizedField?: string | null;
147+
richText?: {
148+
root: {
149+
type: string;
150+
children: {
151+
type: any;
152+
version: number;
153+
[k: string]: unknown;
154+
}[];
155+
direction: ('ltr' | 'rtl') | null;
156+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
157+
indent: number;
158+
version: number;
159+
};
160+
[k: string]: unknown;
161+
} | null;
162+
richTextInTab?: {
163+
root: {
164+
type: string;
165+
children: {
166+
type: any;
167+
version: number;
168+
[k: string]: unknown;
169+
}[];
170+
direction: ('ltr' | 'rtl') | null;
171+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
172+
indent: number;
173+
version: number;
174+
};
175+
[k: string]: unknown;
176+
} | null;
147177
updatedAt: string;
148178
createdAt: string;
149179
deletedAt?: string | null;
@@ -306,6 +336,8 @@ export interface PagesSelect<T extends boolean = true> {
306336
export interface PostsSelect<T extends boolean = true> {
307337
title?: T;
308338
localizedField?: T;
339+
richText?: T;
340+
richTextInTab?: T;
309341
updatedAt?: T;
310342
createdAt?: T;
311343
deletedAt?: T;

0 commit comments

Comments
 (0)