From e5dc1b45fca7159c11894e9d6067c808719845ab Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 14 Aug 2025 10:46:13 -0400 Subject: [PATCH 1/9] do not get doc permissions on every autosave submit --- packages/ui/src/elements/Autosave/index.tsx | 1 + .../ui/src/providers/DocumentInfo/types.ts | 4 +- .../DocumentInfo/useGetDocPermissions.tsx | 19 +++- packages/ui/src/views/Edit/index.tsx | 4 +- .../form-state/collections/Autosave/index.tsx | 23 ++++ test/form-state/config.ts | 3 +- test/form-state/e2e.spec.ts | 106 ++++++++++++++++++ test/form-state/payload-types.ts | 41 +++++++ 8 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 test/form-state/collections/Autosave/index.tsx diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 96b121792d7..0dfc08f3543 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -155,6 +155,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, action: url, context: { + getDocPermissions: false, incrementVersionCount: false, }, disableFormWhileProcessing: false, diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 4d8d6404c20..cccfe085bd6 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -14,6 +14,8 @@ import type { import React from 'react' +import type { GetDocPermissions } from './useGetDocPermissions.js' + export type DocumentInfoProps = { readonly action?: string readonly AfterDocument?: React.ReactNode @@ -57,7 +59,7 @@ export type DocumentInfoContext = { isLocked: boolean user: ClientUser | number | string } | null> - getDocPermissions: (data?: Data) => Promise + getDocPermissions: GetDocPermissions getDocPreferences: () => Promise incrementVersionCount: () => void isInitializing: boolean diff --git a/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx b/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx index 14fdfbed3ec..defcff84339 100644 --- a/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx +++ b/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx @@ -6,6 +6,8 @@ import React from 'react' import { hasSavePermission as getHasSavePermission } from '../../utilities/hasSavePermission.js' import { isEditing as getIsEditing } from '../../utilities/isEditing.js' +export type GetDocPermissions = (data?: Data) => Promise + export const useGetDocPermissions = ({ id, api, @@ -30,7 +32,7 @@ export const useGetDocPermissions = ({ setDocPermissions: React.Dispatch> setHasPublishPermission: React.Dispatch> setHasSavePermission: React.Dispatch> -}) => +}): GetDocPermissions => React.useCallback( async (data: Data) => { const params = { @@ -111,5 +113,18 @@ export const useGetDocPermissions = ({ ) } }, - [serverURL, api, id, permissions, i18n.language, locale, collectionSlug, globalSlug], + [ + locale, + id, + collectionSlug, + globalSlug, + serverURL, + api, + i18n.language, + setDocPermissions, + setHasSavePermission, + setHasPublishPermission, + permissions?.collections, + permissions?.globals, + ], ) diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 1e78cf46ac6..977d29303e9 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -312,7 +312,9 @@ export function DefaultEditView({ resetUploadEdits() } - await getDocPermissions(json) + if (context?.getDocPermissions !== false) { + await getDocPermissions(json) + } if (id || globalSlug) { const docPreferences = await getDocPreferences() diff --git a/test/form-state/collections/Autosave/index.tsx b/test/form-state/collections/Autosave/index.tsx new file mode 100644 index 00000000000..9065c595e1e --- /dev/null +++ b/test/form-state/collections/Autosave/index.tsx @@ -0,0 +1,23 @@ +import type { CollectionConfig } from 'payload' + +export const autosavePostsSlug = 'autosave-posts' + +export const AutosavePostsCollection: CollectionConfig = { + slug: autosavePostsSlug, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + versions: { + drafts: { + autosave: { + interval: 100, + }, + }, + }, +} diff --git a/test/form-state/config.ts b/test/form-state/config.ts index 07145a70522..61338952bd7 100644 --- a/test/form-state/config.ts +++ b/test/form-state/config.ts @@ -3,13 +3,14 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' +import { AutosavePostsCollection } from './collections/Autosave/index.js' import { PostsCollection, postsSlug } from './collections/Posts/index.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfigWithDefaults({ - collections: [PostsCollection], + collections: [PostsCollection, AutosavePostsCollection], admin: { importMap: { baseDir: path.resolve(dirname), diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index c71ad322b70..b5e077b2890 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -8,6 +8,7 @@ import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js' import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js' import * as path from 'path' +import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import type { Config, Post } from './payload-types.js' @@ -21,6 +22,7 @@ import { import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { autosavePostsSlug } from './collections/Autosave/index.js' import { postsSlug } from './collections/Posts/index.js' const { describe, beforeEach, afterEach } = test @@ -36,11 +38,13 @@ let serverURL: string test.describe('Form State', () => { let page: Page let postsUrl: AdminUrlUtil + let autosavePostsUrl: AdminUrlUtil test.beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) postsUrl = new AdminUrlUtil(serverURL, postsSlug) + autosavePostsUrl = new AdminUrlUtil(serverURL, autosavePostsSlug) context = await browser.newContext() page = await context.newPage() @@ -296,6 +300,108 @@ test.describe('Form State', () => { await cdpSession.detach() }) + test('should render computed values after save', async () => { + await page.goto(postsUrl.create) + const titleField = page.locator('#field-title') + const computedTitleField = page.locator('#field-computedTitle') + + await titleField.fill('Test Title') + + await expect(computedTitleField).toHaveValue('') + + await saveDocAndAssert(page) + + await expect(computedTitleField).toHaveValue('Test Title') + }) + + test('should fetch new doc permissions after save', async () => { + const doc = await createPost({ title: 'Initial Title' }) + await page.goto(postsUrl.edit(doc.id)) + const titleField = page.locator('#field-title') + await expect(titleField).toBeEnabled() + + await assertNetworkRequests( + page, + `${serverURL}/api/posts/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title') + await wait(500) + await page.click('#action-save', { delay: 100 }) + }, + { + allowedNumberOfRequests: 2, + minimumNumberOfRequests: 2, + timeout: 3000, + }, + ) + + await assertNetworkRequests( + page, + `${serverURL}/api/posts/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title 2') + await wait(500) + await page.click('#action-save', { delay: 100 }) + }, + { + minimumNumberOfRequests: 2, + allowedNumberOfRequests: 2, + timeout: 3000, + }, + ) + }) + + test('autosave - should not fetch new doc permissions on every autosave', async () => { + const doc = await payload.create({ + collection: autosavePostsSlug, + data: { + title: 'Initial Title', + }, + }) + + await page.goto(autosavePostsUrl.edit(doc.id)) + const titleField = page.locator('#field-title') + await expect(titleField).toBeEnabled() + + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title') + }, + { + allowedNumberOfRequests: 0, + timeout: 3000, + }, + ) + + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title Again') + }, + { + allowedNumberOfRequests: 0, + timeout: 3000, + }, + ) + + // save manually and ensure the permissions are fetched again + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await page.click('#action-save', { delay: 100 }) + }, + { + allowedNumberOfRequests: 2, + minimumNumberOfRequests: 2, + timeout: 3000, + }, + ) + }) + describe('Throttled tests', () => { let cdpSession: CDPSession diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index dd00cd42ef5..679356fdd79 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -68,6 +68,7 @@ export interface Config { blocks: {}; collections: { posts: Post; + 'autosave-posts': AutosavePost; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -76,6 +77,7 @@ export interface Config { collectionsJoins: {}; collectionsSelect: { posts: PostsSelect | PostsSelect; + 'autosave-posts': AutosavePostsSelect | AutosavePostsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -151,6 +153,17 @@ export interface Post { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-posts". + */ +export interface AutosavePost { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -166,6 +179,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; } /** @@ -179,6 +199,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'autosave-posts'; + value: string | AutosavePost; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -261,6 +285,16 @@ export interface PostsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-posts_select". + */ +export interface AutosavePostsSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". @@ -275,6 +309,13 @@ export interface UsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema From 21c2420326d43041b6670a075583979d641ece87 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 14 Aug 2025 11:05:46 -0400 Subject: [PATCH 2/9] do not render all fields during autosave --- packages/ui/src/elements/Autosave/index.tsx | 6 +++++- packages/ui/src/utilities/buildFormState.ts | 6 ++++-- packages/ui/src/views/Edit/index.tsx | 12 ++++++++++-- test/form-state/collections/Autosave/index.tsx | 7 +++++++ test/form-state/payload-types.ts | 2 ++ 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 0dfc08f3543..3798d17fd9b 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -6,6 +6,8 @@ import { dequal } from 'dequal/lite' import { reduceFieldsToValues, versionDefaults } from 'payload/shared' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' +import type { OnSaveContext } from '../../views/Edit/index.js' + import { useAllFormFields, useForm, @@ -155,9 +157,11 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, action: url, context: { + formState: formStateRef.current, getDocPermissions: false, incrementVersionCount: false, - }, + renderAllFields: false, + } satisfies OnSaveContext, disableFormWhileProcessing: false, disableSuccessStatus: true, method, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 1c718d1e5dc..67683c25887 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -175,18 +175,20 @@ export const buildFormState = async ( ) } - // If there is a form state, + // If there is a form state but no data, // then we can deduce data from that form state - if (formState) { + if (formState && !data) { data = reduceFieldsToValues(formState, true) } let documentData = undefined + if (documentFormState) { documentData = reduceFieldsToValues(documentFormState, true) } let blockData = initialBlockData + if (initialBlockFormState) { blockData = reduceFieldsToValues(initialBlockFormState, true) } diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 977d29303e9..a33720fd27d 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -42,6 +42,13 @@ import './index.scss' const baseClass = 'collection-edit' +export type OnSaveContext = { + formState?: FormState + getDocPermissions?: boolean + incrementVersionCount?: boolean + renderAllFields?: boolean +} + // This component receives props only on _pages_ // When rendered within a drawer, props are empty // This is solely to support custom edit views which get server-rendered @@ -257,7 +264,7 @@ export function DefaultEditView({ ]) const onSave = useCallback( - async (json, context?: Record): Promise => { + async (json, context?: OnSaveContext): Promise => { const controller = handleAbortRef(abortOnSaveRef) const document = json?.doc || json?.result @@ -325,9 +332,10 @@ export function DefaultEditView({ data: document, docPermissions, docPreferences, + formState: context?.formState, globalSlug, operation, - renderAllFields: true, + renderAllFields: context?.renderAllFields ?? true, returnLockStatus: false, schemaPath: schemaPathSegments.join('.'), signal: controller.signal, diff --git a/test/form-state/collections/Autosave/index.tsx b/test/form-state/collections/Autosave/index.tsx index 9065c595e1e..51dd8aad41c 100644 --- a/test/form-state/collections/Autosave/index.tsx +++ b/test/form-state/collections/Autosave/index.tsx @@ -12,6 +12,13 @@ export const AutosavePostsCollection: CollectionConfig = { name: 'title', type: 'text', }, + { + name: 'computedTitle', + type: 'text', + hooks: { + beforeChange: [({ data }) => data?.title], + }, + }, ], versions: { drafts: { diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index 679356fdd79..e70aa1d33dc 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -160,6 +160,7 @@ export interface Post { export interface AutosavePost { id: string; title?: string | null; + computedTitle?: string | null; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; @@ -291,6 +292,7 @@ export interface PostsSelect { */ export interface AutosavePostsSelect { title?: T; + computedTitle?: T; updatedAt?: T; createdAt?: T; _status?: T; From e4d6229ee9de6032c2b0fa26b53a50188d2dbc10 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 15 Aug 2025 09:19:15 -0400 Subject: [PATCH 3/9] fix build --- packages/ui/src/elements/Autosave/index.tsx | 6 ++---- packages/ui/src/forms/Form/types.ts | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 02357978334..c4e070714f4 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -151,9 +151,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate if (!skipSubmission && modifiedRef.current && url) { - const result = await submit<{ - incrementVersionCount: boolean - }>({ + const result = await submit({ acceptValues: { overrideLocalChanges: false, }, @@ -163,7 +161,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) getDocPermissions: false, incrementVersionCount: false, renderAllFields: false, - } satisfies OnSaveContext, + }, disableFormWhileProcessing: false, disableSuccessStatus: true, method, diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 1221ee2022a..c096a5150e6 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -80,14 +80,14 @@ export type FormProps = { } ) -export type SubmitOptions> = { +export type SubmitOptions> = { acceptValues?: AcceptValues action?: string /** * @experimental - Note: this property is experimental and may change in the future. Use at your own discretion. * If you want to pass additional data to the onSuccess callback, you can use this context object. */ - context?: T + context?: C /** * When true, will disable the form while it is processing. * @default true @@ -109,14 +109,14 @@ export type SubmitOptions> = { export type DispatchFields = React.Dispatch -export type Submit = >( - options?: SubmitOptions, +export type Submit = >( + options?: SubmitOptions, e?: React.FormEvent, ) => Promise +{ formState?: FormState; res: T } | void> export type ValidateForm = () => Promise From 5ab7244220c8990e9d24561c85f455c3d1c94331 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 15 Aug 2025 10:17:46 -0400 Subject: [PATCH 4/9] fix live preview tests --- packages/ui/src/elements/Autosave/index.tsx | 11 ++++--- .../live-preview/_components/Button/index.tsx | 4 +-- .../live-preview/_components/Card/index.tsx | 33 ++++--------------- .../live-preview/_components/Footer/index.tsx | 4 +-- .../live-preview/_components/Header/index.tsx | 4 +-- .../live-preview/_components/Link/index.tsx | 8 ++--- .../live-preview/_heros/PostHero/index.tsx | 4 +-- .../live-preview/_components/Button/index.tsx | 4 +-- .../live-preview/_components/Card/index.tsx | 4 +-- .../live-preview/_components/Footer/index.tsx | 4 +-- .../live-preview/_components/Header/index.tsx | 4 +-- .../live-preview/_components/Link/index.tsx | 8 ++--- .../live-preview/_heros/PostHero/index.tsx | 4 +-- 13 files changed, 28 insertions(+), 68 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index c4e070714f4..4b3aaebc290 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -3,7 +3,11 @@ import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import { dequal } from 'dequal/lite' -import { reduceFieldsToValues, versionDefaults } from 'payload/shared' +import { + deepCopyObjectSimpleWithoutReactComponents, + reduceFieldsToValues, + versionDefaults, +} from 'payload/shared' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' import type { OnSaveContext } from '../../views/Edit/index.js' @@ -157,9 +161,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, action: url, context: { - formState: formStateRef.current, + formState: deepCopyObjectSimpleWithoutReactComponents(formStateRef.current), getDocPermissions: false, - incrementVersionCount: false, + incrementVersionCount: !mostRecentVersionIsAutosaved, renderAllFields: false, }, disableFormWhileProcessing: false, @@ -172,7 +176,6 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }) if (result && result?.res?.ok && !mostRecentVersionIsAutosaved) { - incrementVersionCount() setMostRecentVersionIsAutosaved(true) setUnpublishedVersionCount((prev) => prev + 1) } diff --git a/test/live-preview/app/live-preview/_components/Button/index.tsx b/test/live-preview/app/live-preview/_components/Button/index.tsx index 241e9bd3f50..882db92b96c 100644 --- a/test/live-preview/app/live-preview/_components/Button/index.tsx +++ b/test/live-preview/app/live-preview/_components/Button/index.tsx @@ -2,13 +2,11 @@ import type { ElementType } from 'react' -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export type Props = { appearance?: 'default' | 'none' | 'primary' | 'secondary' className?: string diff --git a/test/live-preview/app/live-preview/_components/Card/index.tsx b/test/live-preview/app/live-preview/_components/Card/index.tsx index b4ee0787b8c..24306e6b7f8 100644 --- a/test/live-preview/app/live-preview/_components/Card/index.tsx +++ b/test/live-preview/app/live-preview/_components/Card/index.tsx @@ -1,4 +1,7 @@ -import LinkWithDefault from 'next/link.js' +'use client' + +import LinkImport from 'next/link.js' +const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default import React, { Fragment } from 'react' import type { Post } from '../../../../payload-types.js' @@ -6,8 +9,6 @@ import type { Post } from '../../../../payload-types.js' import { Media } from '../Media/index.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export const Card: React.FC<{ alignItems?: 'center' className?: string @@ -27,10 +28,10 @@ export const Card: React.FC<{ title: titleFromProps, } = props - const { slug, categories, meta, title } = doc || {} + const { slug, meta, title } = doc || {} const { description, image: metaImage } = meta || {} - const hasCategories = categories && Array.isArray(categories) && categories.length > 0 + const hasCategories = false // Categories are not available in this Post type const titleToUse = titleFromProps || title const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space const href = `/live-preview/${relationTo}/${slug}` @@ -48,28 +49,6 @@ export const Card: React.FC<{ )}
- {showCategories && hasCategories && ( -
- {showCategories && hasCategories && ( -
- {categories?.map((category, index) => { - const titleFromCategory = typeof category === 'string' ? category : category.title - - const categoryTitle = titleFromCategory || 'Untitled category' - - const isLast = index === categories.length - 1 - - return ( - - {categoryTitle} - {!isLast && ,  } - - ) - })} -
- )} -
- )} {titleToUse && (

diff --git a/test/live-preview/app/live-preview/_components/Footer/index.tsx b/test/live-preview/app/live-preview/_components/Footer/index.tsx index 426c049f66a..75b55fbea5f 100644 --- a/test/live-preview/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/app/live-preview/_components/Footer/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import { getFooter } from '../../_api/getFooter.js' @@ -6,8 +6,6 @@ import { Gutter } from '../Gutter/index.js' import { CMSLink } from '../Link/index.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export async function Footer() { const footer = await getFooter() diff --git a/test/live-preview/app/live-preview/_components/Header/index.tsx b/test/live-preview/app/live-preview/_components/Header/index.tsx index 46e77196058..70c01beecac 100644 --- a/test/live-preview/app/live-preview/_components/Header/index.tsx +++ b/test/live-preview/app/live-preview/_components/Header/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import { getHeader } from '../../_api/getHeader.js' @@ -6,8 +6,6 @@ import { Gutter } from '../Gutter/index.js' import classes from './index.module.scss' import { HeaderNav } from './Nav/index.js' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export async function Header() { const header = await getHeader() diff --git a/test/live-preview/app/live-preview/_components/Link/index.tsx b/test/live-preview/app/live-preview/_components/Link/index.tsx index d5d273133e2..ccaac79faa0 100644 --- a/test/live-preview/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import NextLinkImport from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../payload-types.js' @@ -6,8 +6,6 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' -const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default - type CMSLinkType = { appearance?: ButtonProps['appearance'] children?: React.ReactNode @@ -48,10 +46,10 @@ export const CMSLink: React.FC = ({ if (href || url) { return ( - + {label && label} {children || null} - + ) } } diff --git a/test/live-preview/app/live-preview/_heros/PostHero/index.tsx b/test/live-preview/app/live-preview/_heros/PostHero/index.tsx index 7cf19c4f5d6..e8f42061268 100644 --- a/test/live-preview/app/live-preview/_heros/PostHero/index.tsx +++ b/test/live-preview/app/live-preview/_heros/PostHero/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../payload-types.js' @@ -10,8 +10,6 @@ import RichText from '../../_components/RichText/index.js' import { formatDateTime } from '../../_utilities/formatDateTime.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export const PostHero: React.FC<{ post: Post }> = ({ post }) => { diff --git a/test/live-preview/prod/app/live-preview/_components/Button/index.tsx b/test/live-preview/prod/app/live-preview/_components/Button/index.tsx index 241e9bd3f50..882db92b96c 100644 --- a/test/live-preview/prod/app/live-preview/_components/Button/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Button/index.tsx @@ -2,13 +2,11 @@ import type { ElementType } from 'react' -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export type Props = { appearance?: 'default' | 'none' | 'primary' | 'secondary' className?: string diff --git a/test/live-preview/prod/app/live-preview/_components/Card/index.tsx b/test/live-preview/prod/app/live-preview/_components/Card/index.tsx index 5cdc765f88d..1ac3abdb88f 100644 --- a/test/live-preview/prod/app/live-preview/_components/Card/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Card/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../../payload-types.js' @@ -6,8 +6,6 @@ import type { Post } from '../../../../../payload-types.js' import { Media } from '../Media/index.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export const Card: React.FC<{ alignItems?: 'center' className?: string diff --git a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx index 426c049f66a..75b55fbea5f 100644 --- a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import { getFooter } from '../../_api/getFooter.js' @@ -6,8 +6,6 @@ import { Gutter } from '../Gutter/index.js' import { CMSLink } from '../Link/index.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export async function Footer() { const footer = await getFooter() diff --git a/test/live-preview/prod/app/live-preview/_components/Header/index.tsx b/test/live-preview/prod/app/live-preview/_components/Header/index.tsx index 46e77196058..70c01beecac 100644 --- a/test/live-preview/prod/app/live-preview/_components/Header/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Header/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import { getHeader } from '../../_api/getHeader.js' @@ -6,8 +6,6 @@ import { Gutter } from '../Gutter/index.js' import classes from './index.module.scss' import { HeaderNav } from './Nav/index.js' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export async function Header() { const header = await getHeader() diff --git a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx index 5b60e5a654d..ff8a6ded44b 100644 --- a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import NextLinkImport from 'next/link.js' +import Link from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../../payload-types.js' @@ -6,8 +6,6 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' -const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default - type CMSLinkType = { appearance?: ButtonProps['appearance'] children?: React.ReactNode @@ -48,10 +46,10 @@ export const CMSLink: React.FC = ({ if (href || url) { return ( - + {label && label} {children || null} - + ) } } diff --git a/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx b/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx index 67cb767b817..27ffd822932 100644 --- a/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx +++ b/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx @@ -1,4 +1,4 @@ -import LinkWithDefault from 'next/link.js' +import Link from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../../payload-types.js' @@ -10,8 +10,6 @@ import RichText from '../../_components/RichText/index.js' import { formatDateTime } from '../../_utilities/formatDateTime.js' import classes from './index.module.scss' -const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default - export const PostHero: React.FC<{ post: Post }> = ({ post }) => { From c20b5a9462756613f7b5194aab794ff7f134eaff Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 19 Aug 2025 14:56:27 -0400 Subject: [PATCH 5/9] apply to submit generally --- packages/ui/src/elements/Autosave/index.tsx | 9 +-------- packages/ui/src/forms/Form/index.tsx | 8 ++++---- packages/ui/src/forms/Form/types.ts | 4 ++++ packages/ui/src/utilities/buildFormState.ts | 10 +++------- packages/ui/src/views/Edit/index.tsx | 7 +++---- 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 4b3aaebc290..c9737eb9638 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -3,11 +3,7 @@ import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import { dequal } from 'dequal/lite' -import { - deepCopyObjectSimpleWithoutReactComponents, - reduceFieldsToValues, - versionDefaults, -} from 'payload/shared' +import { reduceFieldsToValues, versionDefaults } from 'payload/shared' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' import type { OnSaveContext } from '../../views/Edit/index.js' @@ -51,7 +47,6 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) const { docConfig, - incrementVersionCount, lastUpdateTime, mostRecentVersionIsAutosaved, setMostRecentVersionIsAutosaved, @@ -161,10 +156,8 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, action: url, context: { - formState: deepCopyObjectSimpleWithoutReactComponents(formStateRef.current), getDocPermissions: false, incrementVersionCount: !mostRecentVersionIsAutosaved, - renderAllFields: false, }, disableFormWhileProcessing: false, disableSuccessStatus: true, diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 59d5006f193..7061d36a414 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -266,12 +266,12 @@ export const Form: React.FC = (props) => { const data = reduceFieldsToValues(contextRef.current.fields, true) + const serializableFormState = deepCopyObjectSimpleWithoutReactComponents( + contextRef.current.fields, + ) + // Execute server side validations if (Array.isArray(beforeSubmit)) { - const serializableFormState = deepCopyObjectSimpleWithoutReactComponents( - contextRef.current.fields, - ) - let revalidatedFormState: FormState await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => { diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index c096a5150e6..6f7ea884eeb 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -23,6 +23,10 @@ export type FormOnSuccess> = ( * Arbitrary context passed to the onSuccess callback. */ context?: C + /** + * The form state used to retrieve the resulting json data. May be stale. + */ + formState?: FormState }, ) => Promise | void diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 67683c25887..149a50a2ea1 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -134,8 +134,6 @@ export const buildFormState = async ( const selectMode = select ? getSelectMode(select) : undefined - let data = incomingData - if (!collectionSlug && !globalSlug) { throw new Error('Either collectionSlug or globalSlug must be provided') } @@ -175,11 +173,9 @@ export const buildFormState = async ( ) } - // If there is a form state but no data, - // then we can deduce data from that form state - if (formState && !data) { - data = reduceFieldsToValues(formState, true) - } + // If there is form state but no data, deduce data from that form state, e.g. on initial load + // Otherwise, use the incoming data as the source of truth, e.g. on subsequent saves + const data = incomingData || reduceFieldsToValues(formState, true) let documentData = undefined diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index d447d8dc578..23994eed99a 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -44,7 +44,6 @@ import './index.scss' const baseClass = 'collection-edit' export type OnSaveContext = { - formState?: FormState getDocPermissions?: boolean incrementVersionCount?: boolean renderAllFields?: boolean @@ -266,7 +265,7 @@ export function DefaultEditView({ const onSave: FormOnSuccess = useCallback( async (json, options) => { - const { context } = options || {} + const { context, formState } = options || {} const controller = handleAbortRef(abortOnSaveRef) @@ -336,10 +335,10 @@ export function DefaultEditView({ data: document, docPermissions, docPreferences, - formState: context?.formState, + formState, globalSlug, operation, - renderAllFields: context?.renderAllFields ?? true, + renderAllFields: false, returnLockStatus: false, schemaPath: schemaPathSegments.join('.'), signal: controller.signal, From c1fdfb85829bec45bde27e62bc673fea1af78ab2 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 19 Aug 2025 14:58:38 -0400 Subject: [PATCH 6/9] reset changes to live preview link imports --- .../live-preview/_components/Button/index.tsx | 4 ++- .../live-preview/_components/Card/index.tsx | 33 +++++++++++++++---- .../live-preview/_components/Footer/index.tsx | 4 ++- .../live-preview/_components/Header/index.tsx | 4 ++- .../live-preview/_components/Link/index.tsx | 8 +++-- .../live-preview/_heros/PostHero/index.tsx | 4 ++- .../live-preview/_components/Button/index.tsx | 4 ++- .../live-preview/_components/Card/index.tsx | 4 ++- .../live-preview/_components/Footer/index.tsx | 4 ++- .../live-preview/_components/Header/index.tsx | 4 ++- .../live-preview/_components/Link/index.tsx | 8 +++-- .../live-preview/_heros/PostHero/index.tsx | 4 ++- 12 files changed, 64 insertions(+), 21 deletions(-) diff --git a/test/live-preview/app/live-preview/_components/Button/index.tsx b/test/live-preview/app/live-preview/_components/Button/index.tsx index 882db92b96c..241e9bd3f50 100644 --- a/test/live-preview/app/live-preview/_components/Button/index.tsx +++ b/test/live-preview/app/live-preview/_components/Button/index.tsx @@ -2,11 +2,13 @@ import type { ElementType } from 'react' -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export type Props = { appearance?: 'default' | 'none' | 'primary' | 'secondary' className?: string diff --git a/test/live-preview/app/live-preview/_components/Card/index.tsx b/test/live-preview/app/live-preview/_components/Card/index.tsx index 24306e6b7f8..b4ee0787b8c 100644 --- a/test/live-preview/app/live-preview/_components/Card/index.tsx +++ b/test/live-preview/app/live-preview/_components/Card/index.tsx @@ -1,7 +1,4 @@ -'use client' - -import LinkImport from 'next/link.js' -const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default +import LinkWithDefault from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../payload-types.js' @@ -9,6 +6,8 @@ import type { Post } from '../../../../payload-types.js' import { Media } from '../Media/index.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export const Card: React.FC<{ alignItems?: 'center' className?: string @@ -28,10 +27,10 @@ export const Card: React.FC<{ title: titleFromProps, } = props - const { slug, meta, title } = doc || {} + const { slug, categories, meta, title } = doc || {} const { description, image: metaImage } = meta || {} - const hasCategories = false // Categories are not available in this Post type + const hasCategories = categories && Array.isArray(categories) && categories.length > 0 const titleToUse = titleFromProps || title const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space const href = `/live-preview/${relationTo}/${slug}` @@ -49,6 +48,28 @@ export const Card: React.FC<{ )}
+ {showCategories && hasCategories && ( +
+ {showCategories && hasCategories && ( +
+ {categories?.map((category, index) => { + const titleFromCategory = typeof category === 'string' ? category : category.title + + const categoryTitle = titleFromCategory || 'Untitled category' + + const isLast = index === categories.length - 1 + + return ( + + {categoryTitle} + {!isLast && ,  } + + ) + })} +
+ )} +
+ )} {titleToUse && (

diff --git a/test/live-preview/app/live-preview/_components/Footer/index.tsx b/test/live-preview/app/live-preview/_components/Footer/index.tsx index 75b55fbea5f..426c049f66a 100644 --- a/test/live-preview/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/app/live-preview/_components/Footer/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import { getFooter } from '../../_api/getFooter.js' @@ -6,6 +6,8 @@ import { Gutter } from '../Gutter/index.js' import { CMSLink } from '../Link/index.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export async function Footer() { const footer = await getFooter() diff --git a/test/live-preview/app/live-preview/_components/Header/index.tsx b/test/live-preview/app/live-preview/_components/Header/index.tsx index 70c01beecac..46e77196058 100644 --- a/test/live-preview/app/live-preview/_components/Header/index.tsx +++ b/test/live-preview/app/live-preview/_components/Header/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import { getHeader } from '../../_api/getHeader.js' @@ -6,6 +6,8 @@ import { Gutter } from '../Gutter/index.js' import classes from './index.module.scss' import { HeaderNav } from './Nav/index.js' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export async function Header() { const header = await getHeader() diff --git a/test/live-preview/app/live-preview/_components/Link/index.tsx b/test/live-preview/app/live-preview/_components/Link/index.tsx index ccaac79faa0..d5d273133e2 100644 --- a/test/live-preview/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import NextLinkImport from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../payload-types.js' @@ -6,6 +6,8 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' +const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default + type CMSLinkType = { appearance?: ButtonProps['appearance'] children?: React.ReactNode @@ -46,10 +48,10 @@ export const CMSLink: React.FC = ({ if (href || url) { return ( - + {label && label} {children || null} - + ) } } diff --git a/test/live-preview/app/live-preview/_heros/PostHero/index.tsx b/test/live-preview/app/live-preview/_heros/PostHero/index.tsx index e8f42061268..7cf19c4f5d6 100644 --- a/test/live-preview/app/live-preview/_heros/PostHero/index.tsx +++ b/test/live-preview/app/live-preview/_heros/PostHero/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../payload-types.js' @@ -10,6 +10,8 @@ import RichText from '../../_components/RichText/index.js' import { formatDateTime } from '../../_utilities/formatDateTime.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export const PostHero: React.FC<{ post: Post }> = ({ post }) => { diff --git a/test/live-preview/prod/app/live-preview/_components/Button/index.tsx b/test/live-preview/prod/app/live-preview/_components/Button/index.tsx index 882db92b96c..241e9bd3f50 100644 --- a/test/live-preview/prod/app/live-preview/_components/Button/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Button/index.tsx @@ -2,11 +2,13 @@ import type { ElementType } from 'react' -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export type Props = { appearance?: 'default' | 'none' | 'primary' | 'secondary' className?: string diff --git a/test/live-preview/prod/app/live-preview/_components/Card/index.tsx b/test/live-preview/prod/app/live-preview/_components/Card/index.tsx index 1ac3abdb88f..5cdc765f88d 100644 --- a/test/live-preview/prod/app/live-preview/_components/Card/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Card/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../../payload-types.js' @@ -6,6 +6,8 @@ import type { Post } from '../../../../../payload-types.js' import { Media } from '../Media/index.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export const Card: React.FC<{ alignItems?: 'center' className?: string diff --git a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx index 75b55fbea5f..426c049f66a 100644 --- a/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Footer/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import { getFooter } from '../../_api/getFooter.js' @@ -6,6 +6,8 @@ import { Gutter } from '../Gutter/index.js' import { CMSLink } from '../Link/index.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export async function Footer() { const footer = await getFooter() diff --git a/test/live-preview/prod/app/live-preview/_components/Header/index.tsx b/test/live-preview/prod/app/live-preview/_components/Header/index.tsx index 70c01beecac..46e77196058 100644 --- a/test/live-preview/prod/app/live-preview/_components/Header/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Header/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React from 'react' import { getHeader } from '../../_api/getHeader.js' @@ -6,6 +6,8 @@ import { Gutter } from '../Gutter/index.js' import classes from './index.module.scss' import { HeaderNav } from './Nav/index.js' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export async function Header() { const header = await getHeader() diff --git a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx index ff8a6ded44b..5b60e5a654d 100644 --- a/test/live-preview/prod/app/live-preview/_components/Link/index.tsx +++ b/test/live-preview/prod/app/live-preview/_components/Link/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import NextLinkImport from 'next/link.js' import React from 'react' import type { Page, Post } from '../../../../../payload-types.js' @@ -6,6 +6,8 @@ import type { Props as ButtonProps } from '../Button/index.js' import { Button } from '../Button/index.js' +const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default + type CMSLinkType = { appearance?: ButtonProps['appearance'] children?: React.ReactNode @@ -46,10 +48,10 @@ export const CMSLink: React.FC = ({ if (href || url) { return ( - + {label && label} {children || null} - + ) } } diff --git a/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx b/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx index 27ffd822932..67cb767b817 100644 --- a/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx +++ b/test/live-preview/prod/app/live-preview/_heros/PostHero/index.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link.js' +import LinkWithDefault from 'next/link.js' import React, { Fragment } from 'react' import type { Post } from '../../../../../payload-types.js' @@ -10,6 +10,8 @@ import RichText from '../../_components/RichText/index.js' import { formatDateTime } from '../../_utilities/formatDateTime.js' import classes from './index.module.scss' +const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default + export const PostHero: React.FC<{ post: Post }> = ({ post }) => { From f31cb36d619c32c4ee2ea48391921e59bf060b78 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 20 Aug 2025 10:38:13 -0400 Subject: [PATCH 7/9] fix accept values logic and send form state through onSuccess --- packages/ui/src/forms/Form/index.tsx | 1 + .../ui/src/forms/Form/mergeServerFormState.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 7061d36a414..0593c8c49ce 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -379,6 +379,7 @@ export const Form: React.FC = (props) => { if (typeof onSuccess === 'function') { const newFormState = await onSuccess(json, { context, + formState: serializableFormState, }) if (newFormState) { diff --git a/packages/ui/src/forms/Form/mergeServerFormState.ts b/packages/ui/src/forms/Form/mergeServerFormState.ts index 2c1f2bf36a4..76fa2475f93 100644 --- a/packages/ui/src/forms/Form/mergeServerFormState.ts +++ b/packages/ui/src/forms/Form/mergeServerFormState.ts @@ -30,7 +30,10 @@ type Args = { * We typically do not want to merge properties that rely on user input, however, such as values, unless explicitly requested. * Doing this would cause the client to lose any local changes to those fields. * - * This function will also a few defaults, as well as clean up the server response in preparation for the client. + * Note: Local state is the source of truth, not the new server state that is getting merged in. This is critical for array row + * manipulation specifically, where the user may have added, removed, or reordered rows while a request was pending and is now stale. + * + * This function applies some defaults, as well as cleans up the server response in preparation for the client. * e.g. it will set `valid` and `passesCondition` to true if undefined, and remove `addedByServer` from the response. */ export const mergeServerFormState = ({ @@ -51,17 +54,25 @@ export const mergeServerFormState = ({ * a. accept all values when explicitly requested, e.g. on submit * b. only accept values for unmodified fields, e.g. on autosave */ - if ( - !incomingField.addedByServer && - (!acceptValues || - // See `acceptValues` type definition for more details - (typeof acceptValues === 'object' && - acceptValues !== null && - acceptValues?.overrideLocalChanges === false && - currentState[path].isModified)) - ) { - delete incomingField.value - delete incomingField.initialValue + const shouldAcceptValue = + incomingField.addedByServer || + acceptValues === true || + (typeof acceptValues === 'object' && + acceptValues !== null && + // Note: Must be explicitly false, allow null or undefined to mean true + acceptValues.overrideLocalChanges === false && + !currentState[path]?.isModified) + + newState[path] = { + ...currentState[path], + ...(shouldAcceptValue + ? incomingField + : { + ...incomingField, + // Note: Override the value instead of deleting it, as this avoid mutating incomingField + initialValue: currentState[path]?.initialValue, + value: currentState[path]?.value, + }), } newState[path] = { From 640e17b1aa5ddc3f5b23730b5a7f9f0ba72d8db6 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 20 Aug 2025 10:51:15 -0400 Subject: [PATCH 8/9] remove unrelated changes --- packages/ui/src/forms/Form/index.tsx | 9 +++-- .../ui/src/forms/Form/mergeServerFormState.ts | 35 +++++++------------ packages/ui/src/forms/Form/types.ts | 4 --- packages/ui/src/utilities/buildFormState.ts | 10 ++++-- packages/ui/src/views/Edit/index.tsx | 5 ++- 5 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 0593c8c49ce..59d5006f193 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -266,12 +266,12 @@ export const Form: React.FC = (props) => { const data = reduceFieldsToValues(contextRef.current.fields, true) - const serializableFormState = deepCopyObjectSimpleWithoutReactComponents( - contextRef.current.fields, - ) - // Execute server side validations if (Array.isArray(beforeSubmit)) { + const serializableFormState = deepCopyObjectSimpleWithoutReactComponents( + contextRef.current.fields, + ) + let revalidatedFormState: FormState await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => { @@ -379,7 +379,6 @@ export const Form: React.FC = (props) => { if (typeof onSuccess === 'function') { const newFormState = await onSuccess(json, { context, - formState: serializableFormState, }) if (newFormState) { diff --git a/packages/ui/src/forms/Form/mergeServerFormState.ts b/packages/ui/src/forms/Form/mergeServerFormState.ts index 76fa2475f93..2c1f2bf36a4 100644 --- a/packages/ui/src/forms/Form/mergeServerFormState.ts +++ b/packages/ui/src/forms/Form/mergeServerFormState.ts @@ -30,10 +30,7 @@ type Args = { * We typically do not want to merge properties that rely on user input, however, such as values, unless explicitly requested. * Doing this would cause the client to lose any local changes to those fields. * - * Note: Local state is the source of truth, not the new server state that is getting merged in. This is critical for array row - * manipulation specifically, where the user may have added, removed, or reordered rows while a request was pending and is now stale. - * - * This function applies some defaults, as well as cleans up the server response in preparation for the client. + * This function will also a few defaults, as well as clean up the server response in preparation for the client. * e.g. it will set `valid` and `passesCondition` to true if undefined, and remove `addedByServer` from the response. */ export const mergeServerFormState = ({ @@ -54,25 +51,17 @@ export const mergeServerFormState = ({ * a. accept all values when explicitly requested, e.g. on submit * b. only accept values for unmodified fields, e.g. on autosave */ - const shouldAcceptValue = - incomingField.addedByServer || - acceptValues === true || - (typeof acceptValues === 'object' && - acceptValues !== null && - // Note: Must be explicitly false, allow null or undefined to mean true - acceptValues.overrideLocalChanges === false && - !currentState[path]?.isModified) - - newState[path] = { - ...currentState[path], - ...(shouldAcceptValue - ? incomingField - : { - ...incomingField, - // Note: Override the value instead of deleting it, as this avoid mutating incomingField - initialValue: currentState[path]?.initialValue, - value: currentState[path]?.value, - }), + if ( + !incomingField.addedByServer && + (!acceptValues || + // See `acceptValues` type definition for more details + (typeof acceptValues === 'object' && + acceptValues !== null && + acceptValues?.overrideLocalChanges === false && + currentState[path].isModified)) + ) { + delete incomingField.value + delete incomingField.initialValue } newState[path] = { diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 6f7ea884eeb..c096a5150e6 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -23,10 +23,6 @@ export type FormOnSuccess> = ( * Arbitrary context passed to the onSuccess callback. */ context?: C - /** - * The form state used to retrieve the resulting json data. May be stale. - */ - formState?: FormState }, ) => Promise | void diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 149a50a2ea1..f8ab5c21e06 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -134,6 +134,8 @@ export const buildFormState = async ( const selectMode = select ? getSelectMode(select) : undefined + let data = incomingData + if (!collectionSlug && !globalSlug) { throw new Error('Either collectionSlug or globalSlug must be provided') } @@ -173,9 +175,11 @@ export const buildFormState = async ( ) } - // If there is form state but no data, deduce data from that form state, e.g. on initial load - // Otherwise, use the incoming data as the source of truth, e.g. on subsequent saves - const data = incomingData || reduceFieldsToValues(formState, true) + // If there is a form state, + // then we can deduce data from that form state + if (formState) { + data = reduceFieldsToValues(formState, true) + } let documentData = undefined diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 23994eed99a..0c42dc5528f 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -265,7 +265,7 @@ export function DefaultEditView({ const onSave: FormOnSuccess = useCallback( async (json, options) => { - const { context, formState } = options || {} + const { context } = options || {} const controller = handleAbortRef(abortOnSaveRef) @@ -335,10 +335,9 @@ export function DefaultEditView({ data: document, docPermissions, docPreferences, - formState, globalSlug, operation, - renderAllFields: false, + renderAllFields: true, returnLockStatus: false, schemaPath: schemaPathSegments.join('.'), signal: controller.signal, From 0cb77d1312dc20af9a3fae37cff2b74d0f4572d9 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 20 Aug 2025 10:54:07 -0400 Subject: [PATCH 9/9] rm unused type --- packages/ui/src/views/Edit/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 0c42dc5528f..28d26a7df87 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -46,7 +46,6 @@ const baseClass = 'collection-edit' export type OnSaveContext = { getDocPermissions?: boolean incrementVersionCount?: boolean - renderAllFields?: boolean } // This component receives props only on _pages_