fix(ui): autosave in document drawer overwrites local changes (#13587)

Fixes #13574.

When editing an autosave-enabled document within a document drawer, any
changes made will while the autosave is processing are ultimately
discarded from local form state. This makes is difficult or even
impossible to edit fields.

This is because we server-render, then replace, the entire document on
every save. This includes form state, which is stale because it was
rendered while new changes were still being made. We don't need to
re-render the entire view on every save, though, only on create. We
don't do this on the top-level edit view, for example. Instead, we only
need to replace form state.

This change is also a performance improvement because we are no longer
rendering all components unnecessarily, especially on every autosave
interval.

Before:


https://github.com/user-attachments/assets/e9c221bf-4800-4153-af55-8b82e93b3c26

After:


https://github.com/user-attachments/assets/d77ef2f3-b98b-41d6-ba6c-b502b9bb99cc

Note: ignore the flashing autosave status and doc controls. This is
horrible and we're actively fixing it, but is outside the scope of this
PR.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211139422639700
This commit is contained in:
Jacob Fletcher
2025-08-26 13:59:20 -04:00
committed by GitHub
parent cf37433667
commit bd81936ad4
10 changed files with 212 additions and 224 deletions

View File

@@ -70,17 +70,6 @@ export const AddNewRelation: React.FC<Props> = ({
}
if (isNewValue) {
// dispatchOptions({
// collection: collectionConfig,
// // TODO: fix this
// // @ts-expect-error-next-line
// type: 'ADD',
// config,
// docs: [doc],
// i18n,
// sort: true,
// })
if (hasMany === true) {
onChange([
...(Array.isArray(value) ? value : []),
@@ -148,86 +137,89 @@ export const AddNewRelation: React.FC<Props> = ({
label: getTranslation(relatedCollections[0]?.labels.singular, i18n),
})
if (show) {
return (
<div className={baseClass} id={`${path}-add-new`}>
{relatedCollections.length === 1 && (
<Fragment>
<DocumentDrawerToggler
className={[
`${baseClass}__add-button`,
unstyled && `${baseClass}__add-button--unstyled`,
]
.filter(Boolean)
.join(' ')}
onClick={() => setShowTooltip(false)}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{ButtonFromProps ? (
if (!show) {
return null
}
return (
<div className={baseClass} id={`${path}-add-new`}>
{relatedCollections.length === 1 && (
<Fragment>
<DocumentDrawerToggler
className={[
`${baseClass}__add-button`,
unstyled && `${baseClass}__add-button--unstyled`,
]
.filter(Boolean)
.join(' ')}
onClick={() => {
setShowTooltip(false)
}}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{ButtonFromProps ? (
ButtonFromProps
) : (
<Fragment>
<Tooltip className={`${baseClass}__tooltip`} show={showTooltip}>
{label}
</Tooltip>
<PlusIcon />
</Fragment>
)}
</DocumentDrawerToggler>
<DocumentDrawer onSave={onSave} />
</Fragment>
)}
{relatedCollections.length > 1 && (
<Fragment>
<Popup
button={
ButtonFromProps ? (
ButtonFromProps
) : (
<Fragment>
<Tooltip className={`${baseClass}__tooltip`} show={showTooltip}>
{label}
</Tooltip>
<Button
buttonStyle="none"
className={`${baseClass}__add-button`}
tooltip={popupOpen ? undefined : t('fields:addNew')}
>
<PlusIcon />
</Fragment>
)}
</DocumentDrawerToggler>
<DocumentDrawer onSave={onSave} />
</Fragment>
)}
{relatedCollections.length > 1 && (
<Fragment>
<Popup
button={
ButtonFromProps ? (
ButtonFromProps
) : (
<Button
buttonStyle="none"
className={`${baseClass}__add-button`}
tooltip={popupOpen ? undefined : t('fields:addNew')}
>
<PlusIcon />
</Button>
)
}
buttonType="custom"
horizontalAlign="center"
onToggleOpen={onPopupToggle}
render={({ close: closePopup }) => (
<PopupList.ButtonGroup>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection?.slug].create) {
return (
<PopupList.Button
className={`${baseClass}__relation-button--${relatedCollection?.slug}`}
key={relatedCollection?.slug}
onClick={() => {
closePopup()
setSelectedCollection(relatedCollection?.slug)
}}
>
{getTranslation(relatedCollection?.labels?.singular, i18n)}
</PopupList.Button>
)
}
</Button>
)
}
buttonType="custom"
horizontalAlign="center"
onToggleOpen={onPopupToggle}
render={({ close: closePopup }) => (
<PopupList.ButtonGroup>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection?.slug].create) {
return (
<PopupList.Button
className={`${baseClass}__relation-button--${relatedCollection?.slug}`}
key={relatedCollection?.slug}
onClick={() => {
closePopup()
setSelectedCollection(relatedCollection?.slug)
}}
>
{getTranslation(relatedCollection?.labels?.singular, i18n)}
</PopupList.Button>
)
}
return null
})}
</PopupList.ButtonGroup>
)}
size="medium"
/>
{collectionConfig && permissions.collections[collectionConfig?.slug]?.create && (
<DocumentDrawer onSave={onSave} />
return null
})}
</PopupList.ButtonGroup>
)}
</Fragment>
)}
</div>
)
}
return null
size="medium"
/>
{collectionConfig && permissions.collections[collectionConfig?.slug]?.create && (
<DocumentDrawer onSave={onSave} />
)}
</Fragment>
)}
</div>
)
}

View File

@@ -15,7 +15,7 @@ import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.j
import { DocumentDrawerContextProvider } from './Provider.js'
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
id: existingDocID,
id: docID,
collectionSlug,
disableActions,
drawerSlug,
@@ -43,10 +43,11 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const [DocumentView, setDocumentView] = useState<React.ReactNode>(undefined)
const [isLoading, setIsLoading] = useState(true)
const hasInitialized = useRef(false)
const getDocumentView = useCallback(
(docID?: number | string, showLoadingIndicator: boolean = false) => {
(docID?: DocumentDrawerProps['id'], showLoadingIndicator: boolean = false) => {
const controller = handleAbortRef(abortGetDocumentViewRef)
const fetchDocumentView = async () => {
@@ -78,7 +79,6 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
} catch (error) {
toast.error(error?.message || t('error:unspecific'))
closeModal(drawerSlug)
// toast.error(data?.errors?.[0].message || t('error:unspecific'))
}
abortGetDocumentViewRef.current = null
@@ -105,7 +105,9 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
getDocumentView(args.doc.id)
if (args.operation === 'create') {
getDocumentView(args.doc.id)
}
if (typeof onSaveFromProps === 'function') {
void onSaveFromProps({
@@ -151,10 +153,10 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
useEffect(() => {
if (!DocumentView && !hasInitialized.current) {
getDocumentView(existingDocID, true)
getDocumentView(docID, true)
hasInitialized.current = true
}
}, [DocumentView, getDocumentView, existingDocID])
}, [DocumentView, getDocumentView, docID])
// Cleanup any pending requests when the component unmounts
useEffect(() => {

View File

@@ -26,8 +26,8 @@ const formatDocumentDrawerSlug = ({
}: {
collectionSlug: string
depth: number
id: number | string
uuid: string // supply when creating a new document and no id is available
id?: number | string
uuid: string
}) => `doc-drawer_${collectionSlug}_${depth}${id ? `_${id}` : ''}_${uuid}`
export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({

View File

@@ -1,4 +1,4 @@
import type { Data, FormState, Operation } from 'payload'
import type { Data, DefaultDocumentIDType, FormState, Operation } from 'payload'
import type React from 'react'
import type { HTMLAttributes } from 'react'
@@ -7,10 +7,18 @@ import type { DocumentDrawerContextProps } from './Provider.js'
export type DocumentDrawerProps = {
readonly AfterFields?: React.ReactNode
/**
* The slug of the collection to which the document belongs.
*/
readonly collectionSlug: string
readonly disableActions?: boolean
readonly drawerSlug?: string
readonly id?: null | number | string
/**
* The ID of the document to be edited.
* When provided, will be fetched and displayed in the drawer.
* If omitted, will render the "create new" view for the given collection.
*/
readonly id?: DefaultDocumentIDType | null
readonly initialData?: Data
/**
* @deprecated
@@ -43,19 +51,9 @@ export type UseDocumentDrawerContext = {
toggleDrawer: () => void
}
export type UseDocumentDrawer = (args: {
/**
* The slug of the collection to which the document belongs.
*/
collectionSlug: string
/**
* The ID of the document to be edited.
* When provided, will be fetched and displayed in the drawer.
* If omitted, will render the "create new" view for the given collection.
*/
id?: number | string
overrideEntityVisibility?: boolean
}) => [
export type UseDocumentDrawer = (
args: Pick<DocumentDrawerProps, 'collectionSlug' | 'id' | 'overrideEntityVisibility'>,
) => [
// drawer
React.FC<
{

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-compiler/react-compiler -- TODO: fix */
'use client'
import type { ClientUser, DocumentViewClientProps, FormState } from 'payload'
import type { ClientUser, DocumentViewClientProps } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'

View File

@@ -568,7 +568,9 @@ describe('Relationship Field', () => {
).toHaveCount(1)
await drawerField.fill('Updated document')
await saveButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('Updated successfully')
await expect(page.locator('.payload-toast-container').first()).toContainText(
'Updated successfully',
)
await page.locator('.doc-drawer__header-close').click()
await expect(
page.locator('#field-relationshipHasMany .value-container .rs__multi-value'),

View File

@@ -387,6 +387,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -661,6 +668,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -25,6 +25,7 @@
import type { BrowserContext, Dialog, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { postsCollectionSlug } from 'admin/slugs.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -37,6 +38,7 @@ import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
openDocDrawer,
saveDocAndAssert,
// throttleTest,
} from '../helpers.js'
@@ -1315,6 +1317,42 @@ describe('Versions', () => {
await expect(computedTitleField).toHaveValue('Initial')
})
test('- with autosave - does not override local changes to form state after autosave runs within document drawer', async () => {
await payload.create({
collection: autosaveCollectionSlug,
data: {
title: 'This is a test',
description: 'some description',
},
})
const url = new AdminUrlUtil(serverURL, postsCollectionSlug)
await page.goto(url.create)
await page.locator('#field-relationToAutosaves .rs__control').click()
await page.locator('.rs__option:has-text("This is a test")').click()
await openDocDrawer(
page,
'#field-relationToAutosaves .relationship--single-value__drawer-toggler',
)
const titleField = page.locator('#field-title')
await titleField.fill('')
// press slower than the autosave interval, but not faster than the response and processing
await titleField.pressSequentially('Initial', {
delay: 150,
})
const drawer = page.locator('[id^=doc-drawer_autosave-posts_1_]')
await waitForAutoSaveToRunAndComplete(drawer)
await expect(titleField).toHaveValue('Initial')
})
test('- with autosave - does not display success toast after autosave complete', async () => {
const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
await page.goto(url.create)

View File

@@ -71,8 +71,8 @@ export interface Config {
posts: Post;
'autosave-posts': AutosavePost;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPost;
'autosave-with-validate-posts': AutosaveWithValidatePost;
'autosave-multi-select-posts': AutosaveMultiSelectPost;
'autosave-with-validate-posts': AutosaveWithValidatePost;
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
'draft-with-validate-posts': DraftWithValidatePost;
@@ -96,8 +96,8 @@ export interface Config {
posts: PostsSelect<false> | PostsSelect<true>;
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect<false> | AutosaveWithDraftButtonPostsSelect<true>;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect<false> | AutosaveWithValidatePostsSelect<true>;
'autosave-multi-select-posts': AutosaveMultiSelectPostsSelect<false> | AutosaveMultiSelectPostsSelect<true>;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect<false> | AutosaveWithValidatePostsSelect<true>;
'draft-posts': DraftPostsSelect<false> | DraftPostsSelect<true>;
'draft-with-max-posts': DraftWithMaxPostsSelect<false> | DraftWithMaxPostsSelect<true>;
'draft-with-validate-posts': DraftWithValidatePostsSelect<false> | DraftWithValidatePostsSelect<true>;
@@ -282,17 +282,6 @@ export interface AutosaveWithDraftButtonPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts".
*/
export interface AutosaveWithValidatePost {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-multi-select-posts".
@@ -305,6 +294,17 @@ export interface AutosaveMultiSelectPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts".
*/
export interface AutosaveWithValidatePost {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-with-max-posts".
@@ -411,6 +411,7 @@ export interface Diff {
textInNamedTab1InBlock?: string | null;
};
textInUnnamedTab2InBlock?: string | null;
textInRowInUnnamedTab2InBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TabsBlock';
@@ -705,14 +706,14 @@ export interface PayloadLockedDocument {
relationTo: 'autosave-with-draft-button-posts';
value: string | AutosaveWithDraftButtonPost;
} | null)
| ({
relationTo: 'autosave-with-validate-posts';
value: string | AutosaveWithValidatePost;
} | null)
| ({
relationTo: 'autosave-multi-select-posts';
value: string | AutosaveMultiSelectPost;
} | null)
| ({
relationTo: 'autosave-with-validate-posts';
value: string | AutosaveWithValidatePost;
} | null)
| ({
relationTo: 'draft-posts';
value: string | DraftPost;
@@ -860,21 +861,21 @@ export interface AutosaveWithDraftButtonPostsSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts_select".
* via the `definition` "autosave-multi-select-posts_select".
*/
export interface AutosaveWithValidatePostsSelect<T extends boolean = true> {
export interface AutosaveMultiSelectPostsSelect<T extends boolean = true> {
title?: T;
tag?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-multi-select-posts_select".
* via the `definition` "autosave-with-validate-posts_select".
*/
export interface AutosaveMultiSelectPostsSelect<T extends boolean = true> {
export interface AutosaveWithValidatePostsSelect<T extends boolean = true> {
title?: T;
tag?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -1027,6 +1028,7 @@ export interface DiffSelect<T extends boolean = true> {
textInNamedTab1InBlock?: T;
};
textInUnnamedTab2InBlock?: T;
textInRowInUnnamedTab2InBlock?: T;
id?: T;
blockName?: T;
};
@@ -1384,6 +1386,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -21,15 +21,8 @@
"skipLibCheck": true,
"emitDeclarationOnly": true,
"sourceMap": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"types": [
"node",
"jest"
],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "jest"],
"incremental": true,
"isolatedModules": true,
"plugins": [
@@ -38,72 +31,36 @@
}
],
"paths": {
"@payload-config": [
"./test/form-state/config.ts"
],
"@payloadcms/admin-bar": [
"./packages/admin-bar/src"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"
],
"@payloadcms/live-preview-react": [
"./packages/live-preview-react/src/index.ts"
],
"@payloadcms/live-preview-vue": [
"./packages/live-preview-vue/src/index.ts"
],
"@payloadcms/ui": [
"./packages/ui/src/exports/client/index.ts"
],
"@payloadcms/ui/shared": [
"./packages/ui/src/exports/shared/index.ts"
],
"@payloadcms/ui/rsc": [
"./packages/ui/src/exports/rsc/index.ts"
],
"@payloadcms/ui/scss": [
"./packages/ui/src/scss.scss"
],
"@payloadcms/ui/scss/app.scss": [
"./packages/ui/src/scss/app.scss"
],
"@payloadcms/next/*": [
"./packages/next/src/exports/*.ts"
],
"@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
"@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"],
"@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"],
"@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"],
"@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"],
"@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"],
"@payloadcms/next/*": ["./packages/next/src/exports/*.ts"],
"@payloadcms/richtext-lexical/client": [
"./packages/richtext-lexical/src/exports/client/index.ts"
],
"@payloadcms/richtext-lexical/rsc": [
"./packages/richtext-lexical/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"],
"@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"],
"@payloadcms/richtext-slate/client": [
"./packages/richtext-slate/src/exports/client/index.ts"
],
"@payloadcms/plugin-seo/client": [
"./packages/plugin-seo/src/exports/client.ts"
],
"@payloadcms/plugin-sentry/client": [
"./packages/plugin-sentry/src/exports/client.ts"
],
"@payloadcms/plugin-stripe/client": [
"./packages/plugin-stripe/src/exports/client.ts"
],
"@payloadcms/plugin-search/client": [
"./packages/plugin-search/src/exports/client.ts"
],
"@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"],
"@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"],
"@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"],
"@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"],
"@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts"
],
"@payloadcms/plugin-import-export/rsc": [
"./packages/plugin-import-export/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/rsc": [
"./packages/plugin-multi-tenant/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"],
"@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts"
],
@@ -113,42 +70,25 @@
"@payloadcms/plugin-multi-tenant/client": [
"./packages/plugin-multi-tenant/src/exports/client.ts"
],
"@payloadcms/plugin-multi-tenant": [
"./packages/plugin-multi-tenant/src/index.ts"
],
"@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"],
"@payloadcms/plugin-multi-tenant/translations/languages/all": [
"./packages/plugin-multi-tenant/src/translations/index.ts"
],
"@payloadcms/plugin-multi-tenant/translations/languages/*": [
"./packages/plugin-multi-tenant/src/translations/languages/*.ts"
],
"@payloadcms/next": [
"./packages/next/src/exports/*"
],
"@payloadcms/storage-azure/client": [
"./packages/storage-azure/src/exports/client.ts"
],
"@payloadcms/storage-s3/client": [
"./packages/storage-s3/src/exports/client.ts"
],
"@payloadcms/next": ["./packages/next/src/exports/*"],
"@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"],
"@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"],
"@payloadcms/storage-vercel-blob/client": [
"./packages/storage-vercel-blob/src/exports/client.ts"
],
"@payloadcms/storage-gcs/client": [
"./packages/storage-gcs/src/exports/client.ts"
],
"@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"],
"@payloadcms/storage-uploadthing/client": [
"./packages/storage-uploadthing/src/exports/client.ts"
]
}
},
"include": [
"${configDir}/src"
],
"exclude": [
"${configDir}/dist",
"${configDir}/build",
"${configDir}/temp",
"**/*.spec.ts"
]
"include": ["${configDir}/src"],
"exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
}