From c45fbb91492decc88da8c5c775a41d669d36c3e2 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 22 Jul 2024 13:01:52 -0400 Subject: [PATCH] feat!: 700% faster deepCopyObject, refactor deep merging and deep copying, type improvements (#7272) **BREAKING:** - The `deepMerge` exported from payload now handles more complex data and is slower. The old, simple deepMerge is now exported as `deepMergeSimple` - `combineMerge` is no longer exported. You can use `deepMergeWithCombinedArrays` instead - The behavior of the exported `deepCopyObject` and `isPlainObject` may be different and more reliable, as the underlying algorithm has changed --- packages/db-mongodb/package.json | 1 - .../db-mongodb/src/queries/parseParams.ts | 5 +- packages/payload/src/admin/RichText.ts | 8 +- .../src/auth/operations/resetPassword.ts | 2 +- .../local/incrementLoginAttempts.ts | 6 +- .../src/auth/strategies/local/register.ts | 4 +- .../src/collections/config/defaults.ts | 7 +- .../src/collections/config/sanitize.ts | 25 +-- .../payload/src/collections/config/types.ts | 9 +- .../payload/src/collections/dataloader.ts | 4 +- .../src/collections/operations/create.ts | 4 +- .../src/collections/operations/deleteByID.ts | 2 +- .../src/collections/operations/duplicate.ts | 4 +- .../src/collections/operations/update.ts | 4 +- .../src/collections/operations/updateByID.ts | 4 +- packages/payload/src/config/sanitize.ts | 11 +- packages/payload/src/exports/shared.ts | 16 +- .../payload/src/fields/config/sanitize.ts | 5 +- .../payload/src/fields/getDefaultValue.ts | 22 +- .../src/fields/hooks/afterChange/index.ts | 16 +- .../src/fields/hooks/afterChange/promise.ts | 39 ++-- .../hooks/afterChange/traverseFields.ts | 14 +- .../src/fields/hooks/afterRead/index.ts | 12 +- .../src/fields/hooks/afterRead/promise.ts | 30 +-- .../fields/hooks/afterRead/traverseFields.ts | 6 +- .../beforeChange/cloneDataFromOriginalDoc.ts | 6 +- .../hooks/beforeChange/getExistingRowDoc.ts | 6 +- .../src/fields/hooks/beforeChange/index.ts | 16 +- .../src/fields/hooks/beforeChange/promise.ts | 55 ++--- .../hooks/beforeChange/traverseFields.ts | 14 +- .../src/fields/hooks/beforeValidate/index.ts | 14 +- .../fields/hooks/beforeValidate/promise.ts | 30 +-- .../hooks/beforeValidate/traverseFields.ts | 6 +- .../payload/src/fields/mergeBaseFields.ts | 5 +- .../payload/src/globals/operations/findOne.ts | 1 - .../src/globals/operations/findVersionByID.ts | 3 +- .../src/globals/operations/findVersions.ts | 2 +- .../payload/src/globals/operations/update.ts | 7 +- packages/payload/src/index.ts | 24 ++- packages/payload/src/types/index.ts | 14 +- .../payload/src/utilities/combineMerge.ts | 16 -- packages/payload/src/utilities/convertData.ts | 20 -- .../payload/src/utilities/deepCopyObject.ts | 190 ++++++++++++++++-- packages/payload/src/utilities/deepMerge.ts | 55 +++-- .../payload/src/utilities/getCSSVariable.ts | 2 - .../payload/src/utilities/isPlainObject.ts | 28 +-- .../payload/src/utilities/overwriteMerge.ts | 3 - .../utilities/telemetry/events/adminInit.ts | 1 - .../drafts/replaceWithDraftIfAvailable.ts | 3 +- packages/payload/src/versions/saveVersion.ts | 6 +- packages/plugin-form-builder/package.json | 1 - .../src/collections/Forms/index.ts | 6 +- .../src/hooks/resaveChildren.ts | 9 +- packages/plugin-search/package.json | 3 +- packages/plugin-search/src/Search/index.ts | 2 - packages/plugin-seo/src/index.tsx | 4 +- .../src/exports/client/index.ts | 1 - .../src/features/blocks/nodes/BlocksNode.tsx | 8 +- .../blocks/nodes/InlineBlocksNode.tsx | 3 +- .../src/features/blocks/plugin/index.tsx | 5 +- .../src/features/link/feature.server.ts | 2 +- .../src/features/link/nodes/types.ts | 6 +- .../plugins/floatingLinkEditor/utilities.ts | 11 +- .../src/features/typesServer.ts | 7 +- .../src/features/upload/nodes/UploadNode.tsx | 7 +- packages/richtext-lexical/src/index.ts | 15 +- .../src/lexical/utils/cloneDeep.ts | 63 ------ .../populateLexicalPopulationPromises.ts | 3 +- .../recursivelyPopulateFieldsForGraphQL.ts | 4 +- .../src/utilities/fieldsDrawer/Drawer.tsx | 4 +- packages/translations/package.json | 10 + .../scripts/translateNewKeys/index.ts | 7 +- .../translations/src/exports/utilities.ts | 1 + .../translations/src/utilities/cloneDeep.ts | 63 ------ .../translations/src/utilities/deepMerge.ts | 32 --- .../src/utilities/deepMergeSimple.ts | 25 +++ packages/translations/src/utilities/init.ts | 6 +- .../ui/src/elements/withMergedProps/index.tsx | 8 +- packages/ui/src/forms/Form/fieldReducer.ts | 9 +- pnpm-lock.yaml | 9 - test/auth/int.spec.ts | 4 +- test/localization/int.spec.ts | 4 +- 82 files changed, 592 insertions(+), 537 deletions(-) delete mode 100644 packages/payload/src/utilities/combineMerge.ts delete mode 100644 packages/payload/src/utilities/convertData.ts delete mode 100644 packages/payload/src/utilities/getCSSVariable.ts delete mode 100644 packages/payload/src/utilities/overwriteMerge.ts delete mode 100644 packages/richtext-lexical/src/lexical/utils/cloneDeep.ts create mode 100644 packages/translations/src/exports/utilities.ts delete mode 100644 packages/translations/src/utilities/cloneDeep.ts delete mode 100644 packages/translations/src/utilities/deepMerge.ts create mode 100644 packages/translations/src/utilities/deepMergeSimple.ts diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index 6df45167e..2c76cf73e 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -34,7 +34,6 @@ }, "dependencies": { "bson-objectid": "2.0.4", - "deepmerge": "4.3.1", "http-status": "1.6.2", "mongoose": "6.12.3", "mongoose-paginate-v2": "1.7.22", diff --git a/packages/db-mongodb/src/queries/parseParams.ts b/packages/db-mongodb/src/queries/parseParams.ts index 272be27b8..611055fa7 100644 --- a/packages/db-mongodb/src/queries/parseParams.ts +++ b/packages/db-mongodb/src/queries/parseParams.ts @@ -1,8 +1,7 @@ import type { FilterQuery } from 'mongoose' import type { Field, Operator, Payload, Where } from 'payload' -import deepmerge from 'deepmerge' -import { combineMerge } from 'payload' +import { deepMergeWithCombinedArrays } from 'payload' import { validOperators } from 'payload/shared' import { buildAndOrConditions } from './buildAndOrConditions.js' @@ -70,7 +69,7 @@ export async function parseParams({ [searchParam.path]: searchParam.value, } } else if (typeof searchParam?.value === 'object') { - result = deepmerge(result, searchParam.value, { arrayMerge: combineMerge }) + result = deepMergeWithCombinedArrays(result, searchParam.value) } } } diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 8675f437e..7931fd979 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -6,7 +6,7 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../collections/confi import type { SanitizedConfig } from '../config/types.js' import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../types/index.js' import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js' export type RichTextFieldProps = { @@ -82,7 +82,7 @@ export type BeforeChangeRichTextHookArgs< /** * The original data with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks. */ - docWithLocales?: Record + docWithLocales?: JsonObject duplicate?: boolean @@ -98,7 +98,7 @@ export type BeforeChangeRichTextHookArgs< /** * The original siblingData with locales (not modified by any hooks). */ - siblingDocWithLocales?: Record + siblingDocWithLocales?: JsonObject skipValidation?: boolean } @@ -216,7 +216,7 @@ type RichTextAdapterBase< populationPromises: Promise[] req: PayloadRequest showHiddenFields: boolean - siblingDoc: Record + siblingDoc: JsonObject }) => void hooks?: RichTextHooks i18n?: Partial diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index 566aaf420..ac5f6b9bc 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -59,7 +59,7 @@ export const resetPasswordOperation = async (args: Arguments): Promise = collection: collectionConfig.slug, req, where: { - resetPasswordExpiration: { greater_than: new Date() }, + resetPasswordExpiration: { greater_than: new Date().toISOString() }, resetPasswordToken: { equals: data.token }, }, }) diff --git a/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts b/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts index df0311f77..3a227f65f 100644 --- a/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts +++ b/packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts @@ -1,5 +1,5 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js' -import type { Payload } from '../../../index.js' +import type { JsonObject, Payload } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' type Args = { @@ -39,13 +39,13 @@ export const incrementLoginAttempts = async ({ return } - const data: Record = { + const data: JsonObject = { loginAttempts: Number(doc.loginAttempts) + 1, } // Lock the account if at max attempts and not already locked if (typeof doc.loginAttempts === 'number' && doc.loginAttempts + 1 >= maxLoginAttempts) { - const lockUntil = new Date(Date.now() + lockTime) + const lockUntil = new Date(Date.now() + lockTime).toISOString() data.lockUntil = lockUntil } diff --git a/packages/payload/src/auth/strategies/local/register.ts b/packages/payload/src/auth/strategies/local/register.ts index 4b636aebc..9e43c608c 100644 --- a/packages/payload/src/auth/strategies/local/register.ts +++ b/packages/payload/src/auth/strategies/local/register.ts @@ -1,5 +1,5 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' -import type { Payload } from '../../../index.js' +import type { JsonObject, Payload } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' import { ValidationError } from '../../../errors/index.js' @@ -7,7 +7,7 @@ import { generatePasswordSaltHash } from './generatePasswordSaltHash.js' type Args = { collection: SanitizedCollectionConfig - doc: Record + doc: JsonObject password: string payload: Payload req: PayloadRequest diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 74022d9f5..cc7d51bec 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -1,8 +1,9 @@ -import type { LoginWithUsernameOptions } from '../../auth/types.js' +import type { IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js' +import type { CollectionConfig } from './types.js' import defaultAccess from '../../auth/defaultAccess.js' -export const defaults = { +export const defaults: Partial = { access: { create: defaultAccess, delete: defaultAccess, @@ -49,7 +50,7 @@ export const defaults = { versions: false, } -export const authDefaults = { +export const authDefaults: IncomingAuthType = { cookies: { sameSite: 'Lax', secure: false, diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index a973a13cb..b8c6e44a8 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -1,5 +1,3 @@ -import merge from 'deepmerge' - import type { Config, SanitizedConfig } from '../../config/types.js' import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' @@ -9,8 +7,8 @@ import { sanitizeFields } from '../../fields/config/sanitize.js' import { fieldAffectsData } from '../../fields/config/types.js' import mergeBaseFields from '../../fields/mergeBaseFields.js' import { getBaseUploadFields } from '../../uploads/getBaseFields.js' +import { deepMergeWithReactComponents } from '../../utilities/deepMerge.js' import { formatLabels } from '../../utilities/formatLabels.js' -import { isPlainObject } from '../../utilities/isPlainObject.js' import baseVersionFields from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' import { authDefaults, defaults, loginWithUsernameDefaults } from './defaults.js' @@ -29,9 +27,7 @@ export const sanitizeCollection = async ( // Make copy of collection config // ///////////////////////////////// - const sanitized: CollectionConfig = merge(defaults, collection, { - isMergeableObject: isPlainObject, - }) + const sanitized: CollectionConfig = deepMergeWithReactComponents(defaults, collection) // ///////////////////////////////// // Sanitize fields @@ -141,9 +137,10 @@ export const sanitizeCollection = async ( // sanitize fields for reserved names sanitizeAuthFields(sanitized.fields, sanitized) - sanitized.auth = merge(authDefaults, typeof sanitized.auth === 'object' ? sanitized.auth : {}, { - isMergeableObject: isPlainObject, - }) + sanitized.auth = deepMergeWithReactComponents( + authDefaults, + typeof sanitized.auth === 'object' ? sanitized.auth : {}, + ) if (!sanitized.auth.disableLocalStrategy && sanitized.auth.verify === true) { sanitized.auth.verify = {} @@ -157,12 +154,12 @@ export const sanitizeCollection = async ( } sanitized.auth.loginWithUsername = sanitized.auth.loginWithUsername - ? merge( - loginWithUsernameDefaults, - typeof sanitized.auth.loginWithUsername === 'boolean' + ? { + ...loginWithUsernameDefaults, + ...(typeof sanitized.auth.loginWithUsername === 'boolean' ? {} - : sanitized.auth.loginWithUsername, - ) + : sanitized.auth.loginWithUsername), + } : false sanitized.fields = mergeBaseFields(sanitized.fields, getBaseAuthFields(sanitized.auth)) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 39f94941c..adf299c88 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -28,7 +28,12 @@ import type { } from '../../config/types.js' import type { DBIdentifierName } from '../../database/types.js' import type { Field } from '../../fields/config/types.js' -import type { CollectionSlug, TypedAuthOperations, TypedCollection } from '../../index.js' +import type { + CollectionSlug, + JsonObject, + TypedAuthOperations, + TypedCollection, +} from '../../index.js' import type { PayloadRequest, RequestContext } from '../../types/index.js' import type { SanitizedUploadConfig, UploadConfig } from '../../uploads/types.js' import type { @@ -41,7 +46,7 @@ export type DataFromCollectionSlug = TypedCollecti export type AuthOperationsFromCollectionSlug = TypedAuthOperations[TSlug] -export type RequiredDataFromCollection> = MarkOptional< +export type RequiredDataFromCollection = MarkOptional< TData, 'createdAt' | 'id' | 'sizes' | 'updatedAt' > diff --git a/packages/payload/src/collections/dataloader.ts b/packages/payload/src/collections/dataloader.ts index 84d9092ae..a0a9e4449 100644 --- a/packages/payload/src/collections/dataloader.ts +++ b/packages/payload/src/collections/dataloader.ts @@ -2,7 +2,7 @@ import type { BatchLoadFn } from 'dataloader' import DataLoader from 'dataloader' -import type { PayloadRequest } from '../types/index.js' +import type { JsonValue, PayloadRequest } from '../types/index.js' import type { TypeWithID } from './config/types.js' import { isValidID } from '../utilities/isValidID.js' @@ -119,7 +119,7 @@ const batchAndLoadDocs = showHiddenFields: Boolean(showHiddenFields), where: { id: { - in: ids, + in: ids as JsonValue, }, }, }) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 59002c118..ff3e52a62 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -1,6 +1,6 @@ import crypto from 'crypto' -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, JsonObject } from '../../index.js' import type { Document, PayloadRequest } from '../../types/index.js' import type { AfterChangeHook, @@ -184,7 +184,7 @@ export const createOperation = async ( // beforeChange - Fields // ///////////////////////////////////// - const resultWithLocales = await beforeChange>({ + const resultWithLocales = await beforeChange({ collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index a2929d7ee..e352bda08 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -1,4 +1,4 @@ -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, JsonObject, TypeWithID } from '../../index.js' import type { PayloadRequest } from '../../types/index.js' import type { BeforeOperationHook, Collection, DataFromCollectionSlug } from '../config/types.js' diff --git a/packages/payload/src/collections/operations/duplicate.ts b/packages/payload/src/collections/operations/duplicate.ts index 9248a5400..7de7701c8 100644 --- a/packages/payload/src/collections/operations/duplicate.ts +++ b/packages/payload/src/collections/operations/duplicate.ts @@ -194,7 +194,7 @@ export const duplicateOperation = async ( // beforeChange - Fields // ///////////////////////////////////// - result = await beforeChange>({ + result = await beforeChange({ id, collection: collectionConfig, context: req.context, @@ -280,7 +280,7 @@ export const duplicateOperation = async ( // afterChange - Fields // ///////////////////////////////////// - result = await afterChange>({ + result = await afterChange({ collection: collectionConfig, context: req.context, data: versionDoc, diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index da0f12c3a..4295b34c5 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -263,7 +263,7 @@ export const updateOperation = async ( // beforeChange - Fields // ///////////////////////////////////// - let result = await beforeChange>({ + let result = await beforeChange({ id, collection: collectionConfig, context: req.context, @@ -349,7 +349,7 @@ export const updateOperation = async ( // afterChange - Fields // ///////////////////////////////////// - result = await afterChange>({ + result = await afterChange({ collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 11dfcbce8..f6d8876f2 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -236,7 +236,7 @@ export const updateByIDOperation = async ( // beforeChange - Fields // ///////////////////////////////////// - let result = await beforeChange>({ + let result = await beforeChange({ id, collection: collectionConfig, context: req.context, @@ -341,7 +341,7 @@ export const updateByIDOperation = async ( // afterChange - Fields // ///////////////////////////////////// - result = await afterChange>({ + result = await afterChange({ collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index deacaaf90..04c20e544 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -1,7 +1,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations' import { en } from '@payloadcms/translations/languages/en' -import merge from 'deepmerge' +import { deepMergeSimple } from '@payloadcms/translations/utilities' import type { Config, @@ -17,8 +17,7 @@ import { InvalidConfiguration } from '../errors/index.js' import { sanitizeGlobals } from '../globals/config/sanitize.js' import getPreferencesCollection from '../preferences/preferencesCollection.js' import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js' -import { deepMerge } from '../utilities/deepMerge.js' -import { isPlainObject } from '../utilities/isPlainObject.js' +import { deepMergeWithReactComponents } from '../utilities/deepMerge.js' import { defaults } from './defaults.js' const sanitizeAdminConfig = (configToSanitize: Config): Partial => { @@ -48,9 +47,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial } export const sanitizeConfig = async (incomingConfig: Config): Promise => { - const configWithDefaults: Config = merge(defaults, incomingConfig, { - isMergeableObject: isPlainObject, - }) as Config + const configWithDefaults: Config = deepMergeWithReactComponents(defaults, incomingConfig) if (!configWithDefaults?.serverURL) { configWithDefaults.serverURL = '' @@ -163,7 +160,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise= 0) { - config.i18n.translations = deepMerge(config.i18n.translations, config.editor.i18n) + config.i18n.translations = deepMergeSimple(config.i18n.translations, config.editor.i18n) } } diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index e4c429bed..67700590c 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -24,10 +24,18 @@ export { formatFilesize } from '../uploads/formatFilesize.js' export { isImage } from '../uploads/isImage.js' -export { deepCopyObject } from '../utilities/deepCopyObject.js' - -export { deepMerge } from '../utilities/deepMerge.js' +export { + deepCopyObject, + deepCopyObjectComplex, + deepCopyObjectSimple, +} from '../utilities/deepCopyObject.js' +export { + deepMerge, + deepMergeWithCombinedArrays, + deepMergeWithReactComponents, + deepMergeWithSourceArrays, +} from '../utilities/deepMerge.js' export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js' export { getDataByPath } from '../utilities/getDataByPath.js' @@ -55,3 +63,5 @@ export { wait } from '../utilities/wait.js' export { default as wordBoundariesRegex } from '../utilities/wordBoundariesRegex.js' export { versionDefaults } from '../versions/defaults.js' + +export { deepMergeSimple } from '@payloadcms/translations/utilities' diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 09dfd749b..1425e3776 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,3 +1,5 @@ +import { deepMergeSimple } from '@payloadcms/translations/utilities' + import type { CollectionConfig } from '../../collections/config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js' import type { Field } from './types.js' @@ -9,7 +11,6 @@ import { InvalidFieldRelationship, MissingFieldType, } from '../../errors/index.js' -import { deepMerge } from '../../utilities/deepMerge.js' import { formatLabels, toWords } from '../../utilities/formatLabels.js' import { baseBlockFields } from '../baseFields/baseBlockFields.js' import { baseIDField } from '../baseFields/baseIDField.js' @@ -175,7 +176,7 @@ export const sanitizeFields = async ({ } if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) { - config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n) + config.i18n.translations = deepMergeSimple(config.i18n.translations, field.editor.i18n) } } if (richTextSanitizationPromises) { diff --git a/packages/payload/src/fields/getDefaultValue.ts b/packages/payload/src/fields/getDefaultValue.ts index 0ed59c46b..583945aa2 100644 --- a/packages/payload/src/fields/getDefaultValue.ts +++ b/packages/payload/src/fields/getDefaultValue.ts @@ -1,29 +1,31 @@ -import type { PayloadRequest } from '../types/index.js' +import type { JsonValue, PayloadRequest } from '../types/index.js' -import { deepCopyObject } from '../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js' type Args = { - defaultValue: unknown + defaultValue: ((args: any) => JsonValue) | any locale: string | undefined user: PayloadRequest['user'] - value?: unknown + value?: JsonValue } -const getValueWithDefault = ({ defaultValue, locale, user, value }: Args): unknown => { +export const getDefaultValue = async ({ + defaultValue, + locale, + user, + value, +}: Args): Promise => { if (typeof value !== 'undefined') { return value } if (defaultValue && typeof defaultValue === 'function') { - return defaultValue({ locale, user }) + return await defaultValue({ locale, user }) } if (typeof defaultValue === 'object') { - return deepCopyObject(defaultValue) + return deepCopyObjectSimple(defaultValue) } return defaultValue } - -// eslint-disable-next-line no-restricted-exports -export default getValueWithDefault diff --git a/packages/payload/src/fields/hooks/afterChange/index.ts b/packages/payload/src/fields/hooks/afterChange/index.ts index ec0bb5423..046425580 100644 --- a/packages/payload/src/fields/hooks/afterChange/index.ts +++ b/packages/payload/src/fields/hooks/afterChange/index.ts @@ -1,24 +1,24 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' -import { deepCopyObject } from '../../../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { traverseFields } from './traverseFields.js' -type Args = { +type Args = { collection: SanitizedCollectionConfig | null context: RequestContext /** * The data before hooks */ - data: Record | T + data: T /** * The data after hooks */ - doc: Record | T + doc: T global: SanitizedGlobalConfig | null operation: 'create' | 'update' - previousDoc: Record | T + previousDoc: T req: PayloadRequest } @@ -26,7 +26,7 @@ type Args = { * This function is responsible for the following actions, in order: * - Execute field hooks */ -export const afterChange = async >({ +export const afterChange = async ({ collection, context, data, @@ -36,7 +36,7 @@ export const afterChange = async >({ previousDoc, req, }: Args): Promise => { - const doc = deepCopyObject(incomingDoc) + const doc = deepCopyObjectSimple(incomingDoc) await traverseFields({ collection, diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index a0f43b486..76fd813af 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -1,7 +1,7 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -12,8 +12,8 @@ import { traverseFields } from './traverseFields.js' type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record - doc: Record + data: JsonObject + doc: JsonObject field: Field | TabAsField global: SanitizedGlobalConfig | null operation: 'create' | 'update' @@ -25,11 +25,11 @@ type Args = { * The parent's schemaPath (path without indexes). */ parentSchemaPath: string[] - previousDoc: Record - previousSiblingDoc: Record + previousDoc: JsonObject + previousSiblingDoc: JsonObject req: PayloadRequest - siblingData: Record - siblingDoc: Record + siblingData: JsonObject + siblingDoc: JsonObject } // This function is responsible for the following actions, in order: @@ -101,11 +101,11 @@ export const promise = async ({ operation, path: fieldPath, previousDoc, - previousSiblingDoc: previousDoc[field.name] as Record, + previousSiblingDoc: previousDoc[field.name] as JsonObject, req, schemaPath: fieldSchemaPath, - siblingData: (siblingData?.[field.name] as Record) || {}, - siblingDoc: siblingDoc[field.name] as Record, + siblingData: (siblingData?.[field.name] as JsonObject) || {}, + siblingDoc: siblingDoc[field.name] as JsonObject, }) break @@ -128,11 +128,11 @@ export const promise = async ({ operation, path: [...fieldPath, i], previousDoc, - previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record), + previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject), req, schemaPath: fieldSchemaPath, siblingData: siblingData?.[field.name]?.[i] || {}, - siblingDoc: { ...row } || {}, + siblingDoc: ({ ...(row as JsonObject) } as JsonObject) || {}, }), ) }) @@ -147,7 +147,9 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] rows.forEach((row, i) => { - const block = field.blocks.find((blockType) => blockType.slug === row.blockType) + const block = field.blocks.find( + (blockType) => blockType.slug === (row as JsonObject).blockType, + ) if (block) { promises.push( @@ -161,12 +163,11 @@ export const promise = async ({ operation, path: [...fieldPath, i], previousDoc, - previousSiblingDoc: - previousDoc?.[field.name]?.[i] || ({} as Record), + previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject), req, schemaPath: fieldSchemaPath, siblingData: siblingData?.[field.name]?.[i] || {}, - siblingDoc: { ...row } || {}, + siblingDoc: ({ ...(row as JsonObject) } as JsonObject) || {}, }), ) } @@ -205,9 +206,9 @@ export const promise = async ({ let tabPreviousSiblingDoc = siblingDoc if (tabHasName(field)) { - tabSiblingData = siblingData[field.name] as Record - tabSiblingDoc = siblingDoc[field.name] as Record - tabPreviousSiblingDoc = previousDoc[field.name] as Record + tabSiblingData = siblingData[field.name] as JsonObject + tabSiblingDoc = siblingDoc[field.name] as JsonObject + tabPreviousSiblingDoc = previousDoc[field.name] as JsonObject } await traverseFields({ diff --git a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts index a4c69a576..dc701f826 100644 --- a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts @@ -1,6 +1,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -8,18 +8,18 @@ import { promise } from './promise.js' type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record - doc: Record + data: JsonObject + doc: JsonObject fields: (Field | TabAsField)[] global: SanitizedGlobalConfig | null operation: 'create' | 'update' path: (number | string)[] - previousDoc: Record - previousSiblingDoc: Record + previousDoc: JsonObject + previousSiblingDoc: JsonObject req: PayloadRequest schemaPath: string[] - siblingData: Record - siblingDoc: Record + siblingData: JsonObject + siblingDoc: JsonObject } export const traverseFields = async ({ diff --git a/packages/payload/src/fields/hooks/afterRead/index.ts b/packages/payload/src/fields/hooks/afterRead/index.ts index efa3ccf28..30dfc2c54 100644 --- a/packages/payload/src/fields/hooks/afterRead/index.ts +++ b/packages/payload/src/fields/hooks/afterRead/index.ts @@ -1,16 +1,16 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' -import { deepCopyObject } from '../../../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { traverseFields } from './traverseFields.js' -type Args = { +type Args = { collection: SanitizedCollectionConfig | null context: RequestContext currentDepth?: number depth: number - doc: Record + doc: T draft: boolean fallbackLocale: null | string findMany?: boolean @@ -32,7 +32,7 @@ type Args = { * - Populate relationships */ -export async function afterRead(args: Args): Promise { +export async function afterRead(args: Args): Promise { const { collection, context, @@ -50,7 +50,7 @@ export async function afterRead(args: Args): Promise { showHiddenFields, } = args - const doc = deepCopyObject(incomingDoc) + const doc = deepCopyObjectSimple(incomingDoc) const fieldPromises = [] const populationPromises = [] diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index c38beff64..bd04e7ae2 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -1,12 +1,12 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' -import getValueWithDefault from '../../getDefaultValue.js' +import { getDefaultValue } from '../../getDefaultValue.js' import { getFieldPaths } from '../../getFieldPaths.js' import { relationshipPopulationPromise } from './relationshipPopulationPromise.js' import { traverseFields } from './traverseFields.js' @@ -16,7 +16,7 @@ type Args = { context: RequestContext currentDepth: number depth: number - doc: Record + doc: JsonObject draft: boolean fallbackLocale: null | string field: Field | TabAsField @@ -40,7 +40,7 @@ type Args = { populationPromises: Promise[] req: PayloadRequest showHiddenFields: boolean - siblingDoc: Record + siblingDoc: JsonObject triggerAccessControl?: boolean triggerHooks?: boolean } @@ -276,7 +276,7 @@ export const promise = async ({ typeof siblingDoc[field.name] === 'undefined' && typeof field.defaultValue !== 'undefined' ) { - siblingDoc[field.name] = await getValueWithDefault({ + siblingDoc[field.name] = await getDefaultValue({ defaultValue: field.defaultValue, locale, user: req.user, @@ -304,7 +304,7 @@ export const promise = async ({ switch (field.type) { case 'group': { - let groupDoc = siblingDoc[field.name] as Record + let groupDoc = siblingDoc[field.name] as JsonObject if (typeof siblingDoc[field.name] !== 'object') groupDoc = {} traverseFields({ @@ -336,7 +336,7 @@ export const promise = async ({ } case 'array': { - const rows = siblingDoc[field.name] + const rows = siblingDoc[field.name] as JsonObject if (Array.isArray(rows)) { rows.forEach((row, i) => { @@ -389,7 +389,7 @@ export const promise = async ({ req, schemaPath: fieldSchemaPath, showHiddenFields, - siblingDoc: row || {}, + siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, }) @@ -407,7 +407,9 @@ export const promise = async ({ if (Array.isArray(rows)) { rows.forEach((row, i) => { - const block = field.blocks.find((blockType) => blockType.slug === row.blockType) + const block = field.blocks.find( + (blockType) => blockType.slug === (row as JsonObject).blockType, + ) if (block) { traverseFields({ @@ -430,7 +432,7 @@ export const promise = async ({ req, schemaPath: fieldSchemaPath, showHiddenFields, - siblingDoc: row || {}, + siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, }) @@ -440,7 +442,9 @@ export const promise = async ({ Object.values(rows).forEach((localeRows) => { if (Array.isArray(localeRows)) { localeRows.forEach((row, i) => { - const block = field.blocks.find((blockType) => blockType.slug === row.blockType) + const block = field.blocks.find( + (blockType) => blockType.slug === (row as JsonObject).blockType, + ) if (block) { traverseFields({ @@ -463,7 +467,7 @@ export const promise = async ({ req, schemaPath: fieldSchemaPath, showHiddenFields, - siblingDoc: row || {}, + siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, }) @@ -511,7 +515,7 @@ export const promise = async ({ case 'tab': { let tabDoc = siblingDoc if (tabHasName(field)) { - tabDoc = siblingDoc[field.name] as Record + tabDoc = siblingDoc[field.name] as JsonObject if (typeof siblingDoc[field.name] !== 'object') tabDoc = {} } diff --git a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts index 8f1f02bd7..8098b59b5 100644 --- a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts @@ -1,6 +1,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -10,7 +10,7 @@ type Args = { context: RequestContext currentDepth: number depth: number - doc: Record + doc: JsonObject draft: boolean fallbackLocale: null | string /** @@ -28,7 +28,7 @@ type Args = { req: PayloadRequest schemaPath: string[] showHiddenFields: boolean - siblingDoc: Record + siblingDoc: JsonObject triggerAccessControl?: boolean triggerHooks?: boolean } diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts index bed84ec03..0aadae2dc 100644 --- a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts +++ b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts @@ -1,4 +1,8 @@ -export const cloneDataFromOriginalDoc = (originalDocData: unknown): unknown => { +import type { JsonArray, JsonObject } from '../../../types/index.js' + +export const cloneDataFromOriginalDoc = ( + originalDocData: JsonArray | JsonObject, +): JsonArray | JsonObject => { if (Array.isArray(originalDocData)) { return originalDocData.map((row) => { if (typeof row === 'object' && row != null) { diff --git a/packages/payload/src/fields/hooks/beforeChange/getExistingRowDoc.ts b/packages/payload/src/fields/hooks/beforeChange/getExistingRowDoc.ts index 6afaebdb8..fd2df3357 100644 --- a/packages/payload/src/fields/hooks/beforeChange/getExistingRowDoc.ts +++ b/packages/payload/src/fields/hooks/beforeChange/getExistingRowDoc.ts @@ -4,11 +4,9 @@ * this is an existing row, so it should be merged. * Otherwise, return an empty object. */ +import type { JsonObject } from '../../../types/index.js' -export const getExistingRowDoc = ( - incomingRow: Record, - existingRows?: unknown, -): Record => { +export const getExistingRowDoc = (incomingRow: JsonObject, existingRows?: unknown): JsonObject => { if (incomingRow.id && Array.isArray(existingRows)) { const matchedExistingRow = existingRows.find((existingRow) => { if (typeof existingRow === 'object' && 'id' in existingRow) { diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 0bcdd2230..b8f47c033 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -1,17 +1,17 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { Operation, PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, Operation, PayloadRequest, RequestContext } from '../../../types/index.js' import { ValidationError } from '../../../errors/index.js' -import { deepCopyObject } from '../../../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { traverseFields } from './traverseFields.js' -type Args = { +type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record | T - doc: Record | T - docWithLocales: Record + data: T + doc: T + docWithLocales: JsonObject duplicate?: boolean global: SanitizedGlobalConfig | null id?: number | string @@ -29,7 +29,7 @@ type Args = { * - beforeDuplicate hooks (if duplicate) * - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales. */ -export const beforeChange = async >({ +export const beforeChange = async ({ id, collection, context, @@ -42,7 +42,7 @@ export const beforeChange = async >({ req, skipValidation, }: Args): Promise => { - const data = deepCopyObject(incomingData) + const data = deepCopyObjectSimple(incomingData) const mergeLocaleActions = [] const errors: { field: string; message: string }[] = [] diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 62de9ec1d..d36c89add 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -1,12 +1,11 @@ -import merge from 'deepmerge' - import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { Operation, PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, Operation, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, FieldHookArgs, TabAsField, ValidateOptions } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' +import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' import { getFieldPaths } from '../../getFieldPaths.js' import { beforeDuplicate } from './beforeDuplicate.js' @@ -16,9 +15,9 @@ import { traverseFields } from './traverseFields.js' type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record - doc: Record - docWithLocales: Record + data: JsonObject + doc: JsonObject + docWithLocales: JsonObject duplicate: boolean errors: { field: string; message: string }[] field: Field | TabAsField @@ -35,9 +34,9 @@ type Args = { */ parentSchemaPath: string[] req: PayloadRequest - siblingData: Record - siblingDoc: Record - siblingDocWithLocales?: Record + siblingData: JsonObject + siblingDoc: JsonObject + siblingDocWithLocales?: JsonObject skipValidation: boolean } @@ -137,12 +136,12 @@ export const promise = async ({ const validationResult = await field.validate(valueToValidate, { ...field, id, - data: merge(doc, data, { arrayMerge: (_, source) => source }), + data: deepMergeWithSourceArrays(doc, data), jsonError, operation, preferences: { fields: {} }, req, - siblingData: merge(siblingDoc, siblingData, { arrayMerge: (_, source) => source }), + siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData), } as ValidateOptions) if (typeof validationResult === 'string') { @@ -173,7 +172,7 @@ export const promise = async ({ if (localization && field.localized) { mergeLocaleActions.push(async () => { const localeData = await localization.localeCodes.reduce( - async (localizedValuesPromise: Promise>, locale) => { + async (localizedValuesPromise: Promise, locale) => { const localizedValues = await localizedValuesPromise let fieldValue = locale === req.locale @@ -253,9 +252,9 @@ export const promise = async ({ path: fieldPath, req, schemaPath: fieldSchemaPath, - siblingData: siblingData[field.name] as Record, - siblingDoc: siblingDoc[field.name] as Record, - siblingDocWithLocales: siblingDocWithLocales[field.name] as Record, + siblingData: siblingData[field.name] as JsonObject, + siblingDoc: siblingDoc[field.name] as JsonObject, + siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject, skipValidation: skipValidationFromHere, }) @@ -285,9 +284,12 @@ export const promise = async ({ path: [...fieldPath, i], req, schemaPath: fieldSchemaPath, - siblingData: row, - siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), - siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]), + siblingData: row as JsonObject, + siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]), + siblingDocWithLocales: getExistingRowDoc( + row as JsonObject, + siblingDocWithLocales[field.name], + ), skipValidation: skipValidationFromHere, }), ) @@ -305,10 +307,13 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] rows.forEach((row, i) => { - const rowSiblingDoc = getExistingRowDoc(row, siblingDoc[field.name]) - const rowSiblingDocWithLocales = getExistingRowDoc(row, siblingDocWithLocales[field.name]) + const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name]) + const rowSiblingDocWithLocales = getExistingRowDoc( + row as JsonObject, + siblingDocWithLocales[field.name], + ) - const blockTypeToMatch = row.blockType || rowSiblingDoc.blockType + const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch) if (block) { @@ -329,7 +334,7 @@ export const promise = async ({ path: [...fieldPath, i], req, schemaPath: fieldSchemaPath, - siblingData: row, + siblingData: row as JsonObject, siblingDoc: rowSiblingDoc, siblingDocWithLocales: rowSiblingDocWithLocales, skipValidation: skipValidationFromHere, @@ -382,9 +387,9 @@ export const promise = async ({ if (typeof siblingDocWithLocales[field.name] !== 'object') siblingDocWithLocales[field.name] = {} - tabSiblingData = siblingData[field.name] as Record - tabSiblingDoc = siblingDoc[field.name] as Record - tabSiblingDocWithLocales = siblingDocWithLocales[field.name] as Record + tabSiblingData = siblingData[field.name] as JsonObject + tabSiblingDoc = siblingDoc[field.name] as JsonObject + tabSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject } await traverseFields({ diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index ff3df2477..c8251e8c6 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -1,6 +1,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { Operation, PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, Operation, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -8,15 +8,15 @@ import { promise } from './promise.js' type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record + data: JsonObject /** * The original data (not modified by any hooks) */ - doc: Record + doc: JsonObject /** * The original data with locales (not modified by any hooks) */ - docWithLocales: Record + docWithLocales: JsonObject duplicate: boolean errors: { field: string; message: string }[] fields: (Field | TabAsField)[] @@ -27,15 +27,15 @@ type Args = { path: (number | string)[] req: PayloadRequest schemaPath: string[] - siblingData: Record + siblingData: JsonObject /** * The original siblingData (not modified by any hooks) */ - siblingDoc: Record + siblingDoc: JsonObject /** * The original siblingData with locales (not modified by any hooks) */ - siblingDocWithLocales: Record + siblingDocWithLocales: JsonObject skipValidation?: boolean } diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index 0c3395f35..2f6c7e64a 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -1,15 +1,15 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' -import { deepCopyObject } from '../../../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { traverseFields } from './traverseFields.js' -type Args = { +type Args = { collection: SanitizedCollectionConfig | null context: RequestContext - data: Record | T - doc?: Record | T + data: T + doc?: T duplicate?: boolean global: SanitizedGlobalConfig | null id?: number | string @@ -26,7 +26,7 @@ type Args = { * - Merge original document data into incoming data * - Compute default values for undefined fields */ -export const beforeValidate = async >({ +export const beforeValidate = async ({ id, collection, context, @@ -37,7 +37,7 @@ export const beforeValidate = async >({ overrideAccess, req, }: Args): Promise => { - const data = deepCopyObject(incomingData) + const data = deepCopyObjectSimple(incomingData) await traverseFields({ id, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index b1553b31f..35e6f4ad4 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -1,12 +1,12 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, JsonValue, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js' -import getValueWithDefault from '../../getDefaultValue.js' +import { getDefaultValue } from '../../getDefaultValue.js' import { getFieldPaths } from '../../getFieldPaths.js' import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js' import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js' @@ -28,11 +28,11 @@ type Args = { parentPath: (number | string)[] parentSchemaPath: string[] req: PayloadRequest - siblingData: Record + siblingData: JsonObject /** * The original siblingData (not modified by any hooks) */ - siblingDoc: Record + siblingDoc: JsonObject } // This function is responsible for the following actions, in order: @@ -149,7 +149,7 @@ export const promise = async ({ if (Array.isArray(field.relationTo)) { if (Array.isArray(value)) { - value.forEach((relatedDoc: { relationTo: string; value: unknown }, i) => { + value.forEach((relatedDoc: { relationTo: string; value: JsonValue }, i) => { const relatedCollection = req.payload.config.collections.find( (collection) => collection.slug === relatedDoc.relationTo, ) @@ -270,11 +270,11 @@ export const promise = async ({ if (typeof siblingData[field.name] === 'undefined') { // If no incoming data, but existing document data is found, merge it in if (typeof siblingDoc[field.name] !== 'undefined') { - siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name]) + siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name] as any) // Otherwise compute default value } else if (typeof field.defaultValue !== 'undefined') { - siblingData[field.name] = await getValueWithDefault({ + siblingData[field.name] = await getDefaultValue({ defaultValue: field.defaultValue, locale: req.locale, user: req.user, @@ -306,8 +306,8 @@ export const promise = async ({ path: fieldPath, req, schemaPath: fieldSchemaPath, - siblingData: groupData, - siblingDoc: groupDoc, + siblingData: groupData as JsonObject, + siblingDoc: groupDoc as JsonObject, }) break @@ -333,8 +333,8 @@ export const promise = async ({ path: [...fieldPath, i], req, schemaPath: fieldSchemaPath, - siblingData: row, - siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), + siblingData: row as JsonObject, + siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]), }), ) }) @@ -349,12 +349,12 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] rows.forEach((row, i) => { - const rowSiblingDoc = getExistingRowDoc(row, siblingDoc[field.name]) - const blockTypeToMatch = row.blockType || rowSiblingDoc.blockType + const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name]) + const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch) if (block) { - row.blockType = blockTypeToMatch + ;(row as JsonObject).blockType = blockTypeToMatch promises.push( traverseFields({ @@ -370,7 +370,7 @@ export const promise = async ({ path: [...fieldPath, i], req, schemaPath: fieldSchemaPath, - siblingData: row, + siblingData: row as JsonObject, siblingDoc: rowSiblingDoc, }), ) diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index 64fe9d233..d92bbd0f3 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -1,6 +1,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { PayloadRequest, RequestContext } from '../../../types/index.js' +import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -21,11 +21,11 @@ type Args = { path: (number | string)[] req: PayloadRequest schemaPath: string[] - siblingData: Record + siblingData: JsonObject /** * The original siblingData (not modified by any hooks) */ - siblingDoc: Record + siblingDoc: JsonObject } export const traverseFields = async ({ diff --git a/packages/payload/src/fields/mergeBaseFields.ts b/packages/payload/src/fields/mergeBaseFields.ts index 7cb1807e3..6112a8a02 100644 --- a/packages/payload/src/fields/mergeBaseFields.ts +++ b/packages/payload/src/fields/mergeBaseFields.ts @@ -1,7 +1,6 @@ -import merge from 'deepmerge' - import type { Field, FieldWithSubFields } from './config/types.js' +import { deepMergeWithReactComponents } from '../utilities/deepMerge.js' import { fieldAffectsData, fieldHasSubFields } from './config/types.js' const mergeBaseFields = (fields: Field[], baseFields: Field[]): Field[] => { @@ -24,7 +23,7 @@ const mergeBaseFields = (fields: Field[], baseFields: Field[]): Field[] => { const matchCopy: Field = { ...match } mergedFields.splice(matchedIndex, 1) - const mergedField = merge(baseField, matchCopy) + const mergedField = deepMergeWithReactComponents(baseField, matchCopy) if (fieldHasSubFields(baseField) && fieldHasSubFields(matchCopy)) { ;(mergedField as FieldWithSubFields).fields = mergeBaseFields( diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index eb74499d9..998d93d7c 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -1,5 +1,4 @@ import type { AccessResult } from '../../config/types.js' -import type { GeneratedTypes } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' import type { SanitizedGlobalConfig } from '../config/types.js' diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 7bc29606f..1473f76a9 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -8,6 +8,7 @@ import { combineQueries } from '../../database/combineQueries.js' import { Forbidden, NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { commitTransaction } from '../../utilities/commitTransaction.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -78,7 +79,7 @@ export const findVersionByIDOperation = async = an } // Clone the result - it may have come back memoized - let result = JSON.parse(JSON.stringify(results[0])) + let result: any = deepCopyObjectSimple(results[0]) // Patch globalType onto version doc result.version.globalType = globalConfig.slug diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index ee73878ee..9556adb7f 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -87,7 +87,7 @@ export const findVersionsOperation = async >( docs: await Promise.all( paginatedDocs.docs.map(async (data) => ({ ...data, - version: await afterRead({ + version: await afterRead({ collection: null, context: req.context, depth, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 70e66526e..d02f77713 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -1,6 +1,6 @@ import type { DeepPartial } from 'ts-essentials' -import type { GlobalSlug } from '../../index.js' +import type { GlobalSlug, JsonObject } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' import type { DataFromGlobalSlug, SanitizedGlobalConfig } from '../config/types.js' @@ -9,6 +9,7 @@ import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { deepCopyObjectSimple } from '../../index.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -81,10 +82,10 @@ export const updateOperation = async ( where: query, }) - let globalJSON: Record = {} + let globalJSON: JsonObject = {} if (global) { - globalJSON = JSON.parse(JSON.stringify(global)) + globalJSON = deepCopyObjectSimple(global) if (globalJSON._id) { delete globalJSON._id diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index a192a0401..627cca38c 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -50,6 +50,7 @@ import type { Options as FindGlobalVersionByIDOptions } from './globals/operatio import type { Options as FindGlobalVersionsOptions } from './globals/operations/local/findVersions.js' import type { Options as RestoreGlobalVersionOptions } from './globals/operations/local/restoreVersion.js' import type { Options as UpdateGlobalOptions } from './globals/operations/local/update.js' +import type { JsonObject } from './types/index.js' import type { TypeWithVersion } from './versions/types.js' import { decrypt, encrypt } from './auth/crypto.js' @@ -85,10 +86,10 @@ export interface GeneratedTypes { } } collectionsUntyped: { - [slug: string]: Record & TypeWithID + [slug: string]: JsonObject & TypeWithID } globalsUntyped: { - [slug: string]: Record + [slug: string]: JsonObject } localeUntyped: null | string userUntyped: User @@ -925,7 +926,7 @@ export type { ValidateOptions, ValueWithRelation, } from './fields/config/types.js' -export { default as getDefaultValue } from './fields/getDefaultValue.js' +export { getDefaultValue } from './fields/getDefaultValue.js' export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js' export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js' export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js' @@ -964,7 +965,6 @@ export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' export { getFileByPath } from './uploads/getFileByPath.js' export type * from './uploads/types.js' -export { combineMerge } from './utilities/combineMerge.js' export { commitTransaction } from './utilities/commitTransaction.js' export { configToJSONSchema, @@ -974,8 +974,17 @@ export { } from './utilities/configToJSONSchema.js' export { createArrayFromCommaDelineated } from './utilities/createArrayFromCommaDelineated.js' export { createLocalReq } from './utilities/createLocalReq.js' -export { deepCopyObject } from './utilities/deepCopyObject.js' -export { deepMerge } from './utilities/deepMerge.js' +export { + deepCopyObject, + deepCopyObjectComplex, + deepCopyObjectSimple, +} from './utilities/deepCopyObject.js' +export { + deepMerge, + deepMergeWithCombinedArrays, + deepMergeWithReactComponents, + deepMergeWithSourceArrays, +} from './utilities/deepMerge.js' export { default as flattenTopLevelFields } from './utilities/flattenTopLevelFields.js' export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js' export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js' @@ -995,6 +1004,7 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' -export { getDependencies } export { saveVersion } from './versions/saveVersion.js' +export { getDependencies } export type { TypeWithVersion } from './versions/types.js' +export { deepMergeSimple } from '@payloadcms/translations/utilities' diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 72042eb23..d881d1e14 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -68,7 +68,7 @@ type PayloadRequestData = { * 2. import { addDataAndFileToRequest } from '@payloadcms/next/utilities' * `await addDataAndFileToRequest(req)` * */ - data?: Record + data?: JsonObject /** The file on the request, same rules apply as the `data` property */ file?: { data: Buffer @@ -89,8 +89,18 @@ export interface RequestContext { export type Operator = (typeof validOperators)[number] +// Makes it so things like passing new Date() will error +export type JsonValue = JsonArray | JsonObject | unknown //Date | JsonArray | JsonObject | boolean | null | number | string // TODO: Evaluate proper, strong type for this + +export interface JsonArray extends Array {} + +export interface JsonObject { + [key: string]: JsonValue +} + export type WhereField = { - [key in Operator]?: unknown + // any json-serializable value + [key in Operator]?: JsonValue } export type Where = { diff --git a/packages/payload/src/utilities/combineMerge.ts b/packages/payload/src/utilities/combineMerge.ts deleted file mode 100644 index 12ddc304b..000000000 --- a/packages/payload/src/utilities/combineMerge.ts +++ /dev/null @@ -1,16 +0,0 @@ -import merge from 'deepmerge' - -export const combineMerge = (target, source, options) => { - const destination = target.slice() - - source.forEach((item, index) => { - if (typeof destination[index] === 'undefined') { - destination[index] = options.cloneUnlessOtherwiseSpecified(item, options) - } else if (options.isMergeableObject(item)) { - destination[index] = merge(target[index], item, options) - } else if (target.indexOf(item) === -1) { - destination.push(item) - } - }) - return destination -} diff --git a/packages/payload/src/utilities/convertData.ts b/packages/payload/src/utilities/convertData.ts deleted file mode 100644 index d86d1ab94..000000000 --- a/packages/payload/src/utilities/convertData.ts +++ /dev/null @@ -1,20 +0,0 @@ -const convertArrayToObject = (arr, key) => - arr.reduce((obj, item) => { - if (key) { - obj[item[key]] = item - return obj - } - - obj[item] = {} - return obj - }, {}) - -const convertObjectToArray = (arr) => Object.values(arr) - -const convertArrayToHash = (arr, key) => - arr.reduce((obj, item, i) => { - obj[item[key]] = i - return obj - }, {}) - -export { convertArrayToHash, convertArrayToObject, convertObjectToArray } diff --git a/packages/payload/src/utilities/deepCopyObject.ts b/packages/payload/src/utilities/deepCopyObject.ts index b1b37a49d..bd796d8d1 100644 --- a/packages/payload/src/utilities/deepCopyObject.ts +++ b/packages/payload/src/utilities/deepCopyObject.ts @@ -1,23 +1,179 @@ -export const deepCopyObject = (inObject) => { - if (inObject instanceof Date) return inObject +import type { JsonValue } from '../types/index.js' - if (inObject instanceof Set) return new Set(inObject) +/* +Main deepCopyObject handling - from rfdc: https://github.com/davidmarkclements/rfdc/blob/master/index.js - if (inObject instanceof Map) return new Map(inObject) +Copyright 2019 "David Mark Clements " - if (typeof inObject !== 'object' || inObject === null) { - return inObject // Return the value if inObject is not an object +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +*/ + +function copyBuffer(cur) { + if (cur instanceof Buffer) { + return Buffer.from(cur) } - // Create an array or object to hold the values - const outObject = Array.isArray(inObject) ? [] : {} - - Object.keys(inObject).forEach((key) => { - const value = inObject[key] - - // Recursively (deep) copy for nested objects, including arrays - outObject[key] = typeof value === 'object' && value !== null ? deepCopyObject(value) : value - }) - - return outObject + return new cur.constructor(cur.buffer.slice(), cur.byteOffset, cur.length) +} + +const constructorHandlers = new Map() +constructorHandlers.set(Date, (o) => new Date(o)) +constructorHandlers.set(Map, (o, fn) => new Map(cloneArray(Array.from(o), fn))) +constructorHandlers.set(Set, (o, fn) => new Set(cloneArray(Array.from(o), fn))) +let handler = null + +function cloneArray(a: T, fn): T { + const keys = Object.keys(a) + const a2 = new Array(keys.length) + for (let i = 0; i < keys.length; i++) { + const k = keys[i] + const cur = a[k] + if (typeof cur !== 'object' || cur === null) { + a2[k] = cur + } else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) { + a2[k] = handler(cur, fn) + } else if (ArrayBuffer.isView(cur)) { + a2[k] = copyBuffer(cur) + } else { + a2[k] = fn(cur) + } + } + return a2 as T +} + +export const deepCopyObject = (o: T): T => { + if (typeof o !== 'object' || o === null) return o + if (Array.isArray(o)) return cloneArray(o, deepCopyObject) + if (o.constructor !== Object && (handler = constructorHandlers.get(o.constructor))) { + return handler(o, deepCopyObject) + } + const o2 = {} + for (const k in o) { + if (Object.hasOwnProperty.call(o, k) === false) continue + const cur = o[k] + if (typeof cur !== 'object' || cur === null) { + o2[k as string] = cur + } else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) { + o2[k as string] = handler(cur, deepCopyObject) + } else if (ArrayBuffer.isView(cur)) { + o2[k as string] = copyBuffer(cur) + } else { + o2[k as string] = deepCopyObject(cur) + } + } + return o2 as T +} + +/* +Fast deepCopyObjectSimple handling - from fast-json-clone: https://github.com/rhysd/fast-json-clone + +Benchmark: https://github.com/AlessioGr/fastest-deep-clone-json/blob/main/test/benchmark.js +*/ + +/** + * A deepCopyObject implementation which only works for JSON objects and arrays, and is faster than + * JSON.parse(JSON.stringify(obj)) + * + * @param value The JSON value to be cloned. There are two invariants. 1) It must not contain circles + * as JSON does not allow it. This function will cause infinite loop for such values by + * design. 2) It must contain JSON values only. Other values like `Date`, `Regexp`, `Map`, + * `Set`, `Buffer`, ... are not allowed. + * @returns The cloned JSON value. + */ +export function deepCopyObjectSimple(value: T): T { + if (typeof value !== 'object' || value === null) { + return value + } else if (Array.isArray(value)) { + return value.map((e) => + typeof e !== 'object' || e === null ? e : deepCopyObjectSimple(e), + ) as T + } else { + if (value instanceof Date) return new Date(value) as unknown as T + const ret: { [key: string]: T } = {} + for (const k in value) { + const v = value[k] + ret[k] = typeof v !== 'object' || v === null ? v : (deepCopyObjectSimple(v as T) as any) + } + return ret as unknown as T + } +} + +/** + * A deepCopyObject implementation which is slower than deepCopyObject, but more correct. + * Can be used if correctness is more important than speed. Supports circular dependencies + */ +export function deepCopyObjectComplex(object: T, cache: WeakMap = new WeakMap()): T { + if (object === null) return null + + if (cache.has(object)) { + return cache.get(object) + } + + // Handle Date + if (object instanceof Date) { + return new Date(object.getTime()) as unknown as T + } + + // Handle RegExp + if (object instanceof RegExp) { + return new RegExp(object.source, object.flags) as unknown as T + } + + // Handle Map + if (object instanceof Map) { + const clonedMap = new Map() + cache.set(object, clonedMap) + for (const [key, value] of object.entries()) { + clonedMap.set(key, deepCopyObjectComplex(value, cache)) + } + return clonedMap as unknown as T + } + + // Handle Set + if (object instanceof Set) { + const clonedSet = new Set() + cache.set(object, clonedSet) + for (const value of object.values()) { + clonedSet.add(deepCopyObjectComplex(value, cache)) + } + return clonedSet as unknown as T + } + + // Handle Array and Object + if (typeof object === 'object' && object !== null) { + if ('$$typeof' in object && typeof object.$$typeof === 'symbol') { + return object + } + + const clonedObject: any = Array.isArray(object) + ? [] + : Object.create(Object.getPrototypeOf(object)) + cache.set(object, clonedObject) + + for (const key in object) { + if ( + Object.prototype.hasOwnProperty.call(object, key) || + Object.getOwnPropertySymbols(object).includes(key as any) + ) { + clonedObject[key] = deepCopyObjectComplex(object[key], cache) + } + } + + return clonedObject as T + } + + // Handle all other cases + return object } diff --git a/packages/payload/src/utilities/deepMerge.ts b/packages/payload/src/utilities/deepMerge.ts index 2efcc4796..d5a89f1be 100644 --- a/packages/payload/src/utilities/deepMerge.ts +++ b/packages/payload/src/utilities/deepMerge.ts @@ -1,15 +1,46 @@ -export function deepMerge(obj1, obj2) { - const output = { ...obj1 } +import deepMerge from 'deepmerge' - for (const key in obj2) { - if (Object.prototype.hasOwnProperty.call(obj2, key)) { - if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) { - output[key] = deepMerge(obj1[key], obj2[key]) - } else { - output[key] = obj2[key] - } - } - } +import { isPlainObject } from './isPlainObject.js' - return output +export { deepMerge } +/** + * Fully-featured deepMerge. + * + * Array handling: Arrays in the target object are combined with the source object's arrays. + */ +export function deepMergeWithCombinedArrays(obj1: object, obj2: object): T { + return deepMerge(obj1, obj2, { + arrayMerge: (target, source, options) => { + const destination = target.slice() + + source.forEach((item, index) => { + if (typeof destination[index] === 'undefined') { + destination[index] = options.cloneUnlessOtherwiseSpecified(item, options) + } else if (options.isMergeableObject(item)) { + destination[index] = deepMerge(target[index], item, options) + } else if (target.indexOf(item) === -1) { + destination.push(item) + } + }) + return destination + }, + }) +} + +/** + * Fully-featured deepMerge. + * + * Array handling: Arrays in the target object are replaced by the source object's arrays. + */ +export function deepMergeWithSourceArrays(obj1: object, obj2: object): T { + return deepMerge(obj1, obj2, { arrayMerge: (_, source) => source }) +} + +/** + * Fully-featured deepMerge. Does not clone React components by default. + */ +export function deepMergeWithReactComponents(obj1: object, obj2: object): T { + return deepMerge(obj1, obj2, { + isMergeableObject: isPlainObject, + }) } diff --git a/packages/payload/src/utilities/getCSSVariable.ts b/packages/payload/src/utilities/getCSSVariable.ts deleted file mode 100644 index 2a4b7820e..000000000 --- a/packages/payload/src/utilities/getCSSVariable.ts +++ /dev/null @@ -1,2 +0,0 @@ -export default (variable) => - getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`) diff --git a/packages/payload/src/utilities/isPlainObject.ts b/packages/payload/src/utilities/isPlainObject.ts index d6e8f3fc9..73625d135 100644 --- a/packages/payload/src/utilities/isPlainObject.ts +++ b/packages/payload/src/utilities/isPlainObject.ts @@ -1,29 +1,11 @@ -function isObject(o: unknown): boolean { - return Object.prototype.toString.call(o) === '[object Object]' -} +import { isReactComponentOrFunction } from './isReactComponent.js' -export function isPlainObject(o: unknown): boolean { +export function isPlainObject(o: any): boolean { // Is this a React component? - if (typeof o === 'object' && '$$typeof' in o && typeof o.$$typeof === 'symbol') { + if (isReactComponentOrFunction(o)) { return false } - if (isObject(o) === false) return false - - // If has modified constructor - const ctor = o.constructor - if (ctor === undefined) return true - - // If has modified prototype - const prot = ctor.prototype - if (isObject(prot) === false) return false - - // If constructor does not have an Object-specific method - // eslint-disable-next-line no-prototype-builtins - if (prot.hasOwnProperty('isPrototypeOf') === false) { - return false - } - - // Most likely a plain Object - return true + // from https://github.com/fastify/deepmerge/blob/master/index.js#L77 + return typeof o === 'object' && o !== null && !(o instanceof RegExp) && !(o instanceof Date) } diff --git a/packages/payload/src/utilities/overwriteMerge.ts b/packages/payload/src/utilities/overwriteMerge.ts deleted file mode 100644 index dea055bb6..000000000 --- a/packages/payload/src/utilities/overwriteMerge.ts +++ /dev/null @@ -1,3 +0,0 @@ -const overwriteMerge = (_, sourceArray) => sourceArray - -export default overwriteMerge diff --git a/packages/payload/src/utilities/telemetry/events/adminInit.ts b/packages/payload/src/utilities/telemetry/events/adminInit.ts index a9b0f8c37..7037c50d1 100644 --- a/packages/payload/src/utilities/telemetry/events/adminInit.ts +++ b/packages/payload/src/utilities/telemetry/events/adminInit.ts @@ -1,4 +1,3 @@ -import type { User } from '../../../auth/types.js' import type { Payload } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 494a045b3..a2db0588b 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -7,6 +7,7 @@ import type { PayloadRequest, Where } from '../../types/index.js' import { hasWhereAccessResult } from '../../auth/index.js' import { combineQueries } from '../../database/combineQueries.js' import { docHasTimestamps } from '../../types/index.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js' import { appendVersionToQueryKey } from './appendVersionToQueryKey.js' @@ -84,7 +85,7 @@ const replaceWithDraftIfAvailable = async ({ return doc } - draft = JSON.parse(JSON.stringify(draft)) + draft = deepCopyObjectSimple(draft) draft = sanitizeInternalFields(draft) // Patch globalType onto version doc diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index cd3bf2f11..eee2fd1a4 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -1,9 +1,9 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' -import type { Payload } from '../index.js' +import type { Payload } from '../index.js'; import type { PayloadRequest } from '../types/index.js' -import { deepCopyObject } from '../utilities/deepCopyObject.js' +import { deepCopyObjectSimple } from '../index.js' import sanitizeInternalFields from '../utilities/sanitizeInternalFields.js' import { enforceMaxVersions } from './enforceMaxVersions.js' @@ -31,7 +31,7 @@ export const saveVersion = async ({ let result let createNewVersion = true const now = new Date().toISOString() - const versionData = deepCopyObject(doc) + const versionData = deepCopyObjectSimple(doc) if (draft) versionData._status = 'draft' if (versionData._id) delete versionData._id diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index 72d06d8e9..03a55a2d9 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -48,7 +48,6 @@ }, "dependencies": { "@payloadcms/ui": "workspace:*", - "deepmerge": "^4.2.2", "escape-html": "^1.0.3" }, "devDependencies": { diff --git a/packages/plugin-form-builder/src/collections/Forms/index.ts b/packages/plugin-form-builder/src/collections/Forms/index.ts index f87756ff6..37a96f504 100644 --- a/packages/plugin-form-builder/src/collections/Forms/index.ts +++ b/packages/plugin-form-builder/src/collections/Forms/index.ts @@ -1,6 +1,6 @@ import type { Block, CollectionConfig, Field } from 'payload' -import merge from 'deepmerge' +import { deepMergeWithSourceArrays } from 'payload' import type { FieldConfig, FormBuilderPluginConfig } from '../../types.js' @@ -84,9 +84,7 @@ export const generateFormCollection = (formConfig: FormBuilderPluginConfig): Col } if (typeof block === 'object' && typeof fieldConfig === 'object') { - return merge(block, fieldConfig, { - arrayMerge: (_, sourceArray) => sourceArray, - }) + return deepMergeWithSourceArrays(block, fieldConfig) } if (typeof block === 'function') { diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index 33475770c..03b6326c3 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -1,4 +1,9 @@ -import type { CollectionAfterChangeHook, CollectionConfig, PayloadRequest } from 'payload' +import type { + CollectionAfterChangeHook, + CollectionConfig, + JsonObject, + PayloadRequest, +} from 'payload' import type { NestedDocsPluginConfig } from '../types.js' @@ -6,7 +11,7 @@ import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js' type ResaveArgs = { collection: CollectionConfig - doc: Record + doc: JsonObject draft: boolean pluginConfig: NestedDocsPluginConfig req: PayloadRequest diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 45cfa5083..6825af03b 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -44,8 +44,7 @@ "test": "echo \"Error: no tests specified\"" }, "dependencies": { - "@payloadcms/ui": "workspace:*", - "deepmerge": "4.3.1" + "@payloadcms/ui": "workspace:*" }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", diff --git a/packages/plugin-search/src/Search/index.ts b/packages/plugin-search/src/Search/index.ts index 894a1e3e7..0a8b7c134 100644 --- a/packages/plugin-search/src/Search/index.ts +++ b/packages/plugin-search/src/Search/index.ts @@ -1,7 +1,5 @@ import type { CollectionConfig, Field } from 'payload' -import deepMerge from 'deepmerge' - import type { SearchPluginConfig } from '../types.js' import { LinkToDoc } from './ui/index.js' diff --git a/packages/plugin-seo/src/index.tsx b/packages/plugin-seo/src/index.tsx index 407c7dba5..e98d02b9b 100644 --- a/packages/plugin-seo/src/index.tsx +++ b/packages/plugin-seo/src/index.tsx @@ -2,7 +2,7 @@ import type { Config, Field, GroupField, TabsField, TextField } from 'payload' import { addDataAndFileToRequest } from '@payloadcms/next/utilities' import { withMergedProps } from '@payloadcms/ui/shared' -import { deepMerge } from 'payload/shared' +import { deepMergeSimple } from 'payload/shared' import type { GenerateDescription, @@ -298,7 +298,7 @@ export const seoPlugin = i18n: { ...config.i18n, translations: { - ...deepMerge(translations, config.i18n?.translations), + ...deepMergeSimple(translations, config.i18n?.translations), }, }, } diff --git a/packages/richtext-lexical/src/exports/client/index.ts b/packages/richtext-lexical/src/exports/client/index.ts index 0c826f841..f5e03431a 100644 --- a/packages/richtext-lexical/src/exports/client/index.ts +++ b/packages/richtext-lexical/src/exports/client/index.ts @@ -54,7 +54,6 @@ export { sanitizeClientFeatures, } from '../../lexical/config/client/sanitize.js' export { CAN_USE_DOM } from '../../lexical/utils/canUseDOM.js' -export { cloneDeep } from '../../lexical/utils/cloneDeep.js' export { getDOMRangeRect } from '../../lexical/utils/getDOMRangeRect.js' export { getSelectedNode } from '../../lexical/utils/getSelectedNode.js' export { isHTMLElement } from '../../lexical/utils/guard.js' diff --git a/packages/richtext-lexical/src/features/blocks/nodes/BlocksNode.tsx b/packages/richtext-lexical/src/features/blocks/nodes/BlocksNode.tsx index ac83132cc..e2407cb43 100644 --- a/packages/richtext-lexical/src/features/blocks/nodes/BlocksNode.tsx +++ b/packages/richtext-lexical/src/features/blocks/nodes/BlocksNode.tsx @@ -9,12 +9,14 @@ import type { NodeKey, Spread, } from 'lexical' +import type { JsonObject } from 'payload' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import ObjectID from 'bson-objectid' +import { deepCopyObjectSimple } from 'payload/shared' import React, { type JSX } from 'react' -export type BlockFields> = { +export type BlockFields = { /** Block form data */ blockName: string blockType: string @@ -27,7 +29,7 @@ const BlockComponent = React.lazy(() => })), ) -export type SerializedBlockNode> = Spread< +export type SerializedBlockNode = Spread< { children?: never // required so that our typed editor state doesn't automatically add children fields: BlockFields @@ -117,7 +119,7 @@ export class BlockNode extends DecoratorBlockNode { } setFields(fields: BlockFields): void { - const fieldsCopy = JSON.parse(JSON.stringify(fields)) as BlockFields + const fieldsCopy = deepCopyObjectSimple(fields) const writable = this.getWritable() writable.__fields = fieldsCopy diff --git a/packages/richtext-lexical/src/features/blocks/nodes/InlineBlocksNode.tsx b/packages/richtext-lexical/src/features/blocks/nodes/InlineBlocksNode.tsx index 06a2701b5..8609423e4 100644 --- a/packages/richtext-lexical/src/features/blocks/nodes/InlineBlocksNode.tsx +++ b/packages/richtext-lexical/src/features/blocks/nodes/InlineBlocksNode.tsx @@ -11,6 +11,7 @@ import type { import ObjectID from 'bson-objectid' import { DecoratorNode } from 'lexical' +import { deepCopyObjectSimple } from 'payload/shared' import React, { type JSX } from 'react' export type InlineBlockFields = { @@ -112,7 +113,7 @@ export class InlineBlockNode extends DecoratorNode { } setFields(fields: InlineBlockFields): void { - const fieldsCopy = JSON.parse(JSON.stringify(fields)) as InlineBlockFields + const fieldsCopy = deepCopyObjectSimple(fields) as InlineBlockFields const writable = this.getWritable() writable.__fields = fieldsCopy diff --git a/packages/richtext-lexical/src/features/blocks/plugin/index.tsx b/packages/richtext-lexical/src/features/blocks/plugin/index.tsx index d033bee21..efec302e5 100644 --- a/packages/richtext-lexical/src/features/blocks/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/plugin/index.tsx @@ -99,12 +99,14 @@ export const BlocksPlugin: PluginComponent = () => { if (!node) { return false } + node.setFields(fields as BlockFields) setTargetNodeKey(null) return true } + const inlineBlockNode = $createInlineBlockNode(fields as BlockFields) $insertNodes([inlineBlockNode]) if ($isRootOrShadowRoot(inlineBlockNode.getParentOrThrow())) { @@ -118,9 +120,10 @@ export const BlocksPlugin: PluginComponent = () => { editor.registerCommand( OPEN_INLINE_BLOCK_DRAWER_COMMAND, ({ fields, nodeKey }) => { + setBlockFields((fields as BlockFields) ?? null) setTargetNodeKey(nodeKey ?? null) - setBlockType((fields as BlockFields)?.blockType ?? ('' as any)) + setBlockType(fields?.blockType ?? ('' as any)) if (nodeKey) { toggleModal(drawerSlug) diff --git a/packages/richtext-lexical/src/features/link/feature.server.ts b/packages/richtext-lexical/src/features/link/feature.server.ts index 192a8b512..a4f3087ca 100644 --- a/packages/richtext-lexical/src/features/link/feature.server.ts +++ b/packages/richtext-lexical/src/features/link/feature.server.ts @@ -91,7 +91,7 @@ export const LinkFeature = createServerFeature< // Thus, for tasks like validation, we do not want to pass it a text field in the schema which will never have data. // Otherwise, it will cause a validation error (field is required). const sanitizedFieldsWithoutText = deepCopyObject(sanitizedFields).filter( - (field) => field.name !== 'text', + (field) => !('name' in field) || field.name !== 'text', ) return { diff --git a/packages/richtext-lexical/src/features/link/nodes/types.ts b/packages/richtext-lexical/src/features/link/nodes/types.ts index 18d041bf7..eef819556 100644 --- a/packages/richtext-lexical/src/features/link/nodes/types.ts +++ b/packages/richtext-lexical/src/features/link/nodes/types.ts @@ -1,14 +1,14 @@ import type { SerializedElementNode, SerializedLexicalNode, Spread } from 'lexical' +import type { JsonValue } from 'payload' export type LinkFields = { - // unknown, custom fields: - [key: string]: unknown + [key: string]: JsonValue doc: { relationTo: string value: | { // Actual doc data, populated in afterRead hook - [key: string]: unknown + [key: string]: JsonValue id: string } | string diff --git a/packages/richtext-lexical/src/features/link/plugins/floatingLinkEditor/utilities.ts b/packages/richtext-lexical/src/features/link/plugins/floatingLinkEditor/utilities.ts index c33c87d75..4c72829d7 100644 --- a/packages/richtext-lexical/src/features/link/plugins/floatingLinkEditor/utilities.ts +++ b/packages/richtext-lexical/src/features/link/plugins/floatingLinkEditor/utilities.ts @@ -7,7 +7,10 @@ import { getBaseFields } from '../../drawer/baseFields.js' */ export function transformExtraFields( customFieldSchema: - | ((args: { config: SanitizedConfig; defaultFields: FieldAffectingData[] }) => Field[]) + | ((args: { + config: SanitizedConfig + defaultFields: FieldAffectingData[] + }) => (Field | FieldAffectingData)[]) | Field[], config: SanitizedConfig, enabledCollections?: CollectionSlug[], @@ -21,15 +24,15 @@ export function transformExtraFields( maxDepth, ) - let fields: Field[] + let fields: (Field | FieldAffectingData)[] if (typeof customFieldSchema === 'function') { fields = customFieldSchema({ config, defaultFields: baseFields }) } else if (Array.isArray(customFieldSchema)) { fields = customFieldSchema } else { - fields = baseFields as Field[] + fields = baseFields } - return fields + return fields as Field[] } diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 54dec607a..994d18334 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -10,6 +10,7 @@ import type { } from 'lexical' import type { Field, + JsonObject, PayloadRequest, ReplaceAny, RequestContext, @@ -61,7 +62,7 @@ export type PopulationPromise[] req: PayloadRequest showHiddenFields: boolean - siblingDoc: Record + siblingDoc: JsonObject }) => void export type NodeValidation = ({ @@ -241,7 +242,7 @@ export type NodeWithHooks = { getSubFieldsData?: (args: { node: ReturnType['exportJSON']> req: PayloadRequest - }) => Record + }) => JsonObject /** * Allows you to run population logic when a node's data was requested from graphQL. * While `getSubFields` and `getSubFieldsData` automatically handle populating sub-fields (since they run hooks on them), those are only populated in the Rest API. @@ -397,7 +398,7 @@ export type SanitizedServerFeatures = { > getSubFieldsData?: Map< string, - (args: { node: SerializedLexicalNode; req: PayloadRequest }) => Record + (args: { node: SerializedLexicalNode; req: PayloadRequest }) => JsonObject > graphQLPopulationPromises: Map> hooks?: { diff --git a/packages/richtext-lexical/src/features/upload/nodes/UploadNode.tsx b/packages/richtext-lexical/src/features/upload/nodes/UploadNode.tsx index c42cef801..eda65f2d3 100644 --- a/packages/richtext-lexical/src/features/upload/nodes/UploadNode.tsx +++ b/packages/richtext-lexical/src/features/upload/nodes/UploadNode.tsx @@ -8,7 +8,7 @@ import type { NodeKey, Spread, } from 'lexical' -import type { CollectionSlug } from 'payload' +import type { CollectionSlug, JsonObject } from 'payload' import type { JSX } from 'react' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' @@ -21,10 +21,7 @@ const RawUploadComponent = React.lazy(() => ) export type UploadData = { - fields: { - // unknown, custom fields: - [key: string]: unknown - } + fields: JsonObject id: string relationTo: CollectionSlug value: number | string diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index d7f823a6a..a7ac1d1ec 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -13,6 +13,7 @@ import { afterReadTraverseFields, beforeChangeTraverseFields, beforeValidateTraverseFields, + deepCopyObject, getDependencies, withNullableJSONSchemaType, } from 'payload' @@ -35,7 +36,6 @@ import { sanitizeServerEditorConfig, sanitizeServerFeatures, } from './lexical/config/server/sanitize.js' -import { cloneDeep } from './lexical/utils/cloneDeep.js' import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js' import { getGenerateComponentMap } from './utilities/generateComponentMap.js' import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js' @@ -93,10 +93,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte defaultEditorConfig, config, ) - features = cloneDeep(defaultEditorFeatures) + features = deepCopyObject(defaultEditorFeatures) } - finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig) + finalSanitizedEditorConfig = deepCopyObject(defaultSanitizedServerEditorConfig) resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap } else { @@ -109,12 +109,12 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte features = props.features && typeof props.features === 'function' ? props.features({ - defaultFeatures: cloneDeep(defaultEditorFeatures), + defaultFeatures: deepCopyObject(defaultEditorFeatures), rootFeatures: rootEditorFeatures, }) : (props.features as FeatureProviderServer[]) if (!features) { - features = cloneDeep(defaultEditorFeatures) + features = deepCopyObject(defaultEditorFeatures) } const lexical: LexicalEditorConfig = props.lexical @@ -463,8 +463,9 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte recurseNodeTree({ nodeIDMap: originalNodeWithLocalesIDMap, nodes: - (siblingDocWithLocales[field.name] as SerializedEditorState)?.root?.children ?? - [], + + (siblingDocWithLocales[field.name] as unknown as SerializedEditorState)?.root + ?.children ?? [], }) } diff --git a/packages/richtext-lexical/src/lexical/utils/cloneDeep.ts b/packages/richtext-lexical/src/lexical/utils/cloneDeep.ts deleted file mode 100644 index b802b13af..000000000 --- a/packages/richtext-lexical/src/lexical/utils/cloneDeep.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function cloneDeep(object: T, cache: WeakMap = new WeakMap()): T { - if (object === null) return null - - if (cache.has(object)) { - return cache.get(object) - } - - // Handle Date - if (object instanceof Date) { - return new Date(object.getTime()) as unknown as T - } - - // Handle RegExp - if (object instanceof RegExp) { - return new RegExp(object.source, object.flags) as unknown as T - } - - // Handle Map - if (object instanceof Map) { - const clonedMap = new Map() - cache.set(object, clonedMap) - for (const [key, value] of object.entries()) { - clonedMap.set(key, cloneDeep(value, cache)) - } - return clonedMap as unknown as T - } - - // Handle Set - if (object instanceof Set) { - const clonedSet = new Set() - cache.set(object, clonedSet) - for (const value of object.values()) { - clonedSet.add(cloneDeep(value, cache)) - } - return clonedSet as unknown as T - } - - // Handle Array and Object - if (typeof object === 'object' && object !== null) { - if ('$$typeof' in object && typeof object.$$typeof === 'symbol') { - return object - } - - const clonedObject: any = Array.isArray(object) - ? [] - : Object.create(Object.getPrototypeOf(object)) - cache.set(object, clonedObject) - - for (const key in object) { - if ( - Object.prototype.hasOwnProperty.call(object, key) || - Object.getOwnPropertySymbols(object).includes(key as any) - ) { - clonedObject[key] = cloneDeep(object[key], cache) - } - } - - return clonedObject as T - } - - // Handle all other cases - return object -} diff --git a/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts index 50365409c..206f68575 100644 --- a/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts @@ -59,6 +59,7 @@ export const populateLexicalPopulationPromises = ({ } } }, - nodes: (siblingDoc[field?.name] as SerializedEditorState)?.root?.children ?? [], + + nodes: (siblingDoc[field?.name] as unknown as SerializedEditorState)?.root?.children ?? [], }) } diff --git a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts index beaed4c7c..b265a7af4 100644 --- a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts +++ b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts @@ -1,4 +1,4 @@ -import type { Field, PayloadRequest, RequestContext } from 'payload' +import type { Field, JsonObject, PayloadRequest, RequestContext } from 'payload' import { afterReadTraverseFields } from 'payload' @@ -25,7 +25,7 @@ type NestedRichTextFieldsArgs = { populationPromises: Promise[] req: PayloadRequest showHiddenFields: boolean - siblingDoc: Record + siblingDoc: JsonObject } export const recursivelyPopulateFieldsForGraphQL = ({ diff --git a/packages/richtext-lexical/src/utilities/fieldsDrawer/Drawer.tsx b/packages/richtext-lexical/src/utilities/fieldsDrawer/Drawer.tsx index c99a467b5..d528ad58f 100644 --- a/packages/richtext-lexical/src/utilities/fieldsDrawer/Drawer.tsx +++ b/packages/richtext-lexical/src/utilities/fieldsDrawer/Drawer.tsx @@ -1,5 +1,5 @@ 'use client' -import type { Data, FormState } from 'payload' +import type { Data, FormState, JsonObject } from 'payload' import { Drawer } from '@payloadcms/ui' import React from 'react' @@ -12,7 +12,7 @@ export type FieldsDrawerProps = { drawerSlug: string drawerTitle?: string featureKey: string - handleDrawerSubmit: (fields: FormState, data: Record) => void + handleDrawerSubmit: (fields: FormState, data: JsonObject) => void schemaPathSuffix?: string } diff --git a/packages/translations/package.json b/packages/translations/package.json index db151df2d..dd7c401a0 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -19,6 +19,11 @@ "types": "./src/exports/all.ts", "default": "./src/exports/all.ts" }, + "./utilities": { + "import": "./src/exports/utilities.ts", + "types": "./src/exports/utilities.ts", + "default": "./src/exports/utilities.ts" + }, "./languages/*": { "import": "./src/languages/*.ts", "types": "./src/languages/*.ts", @@ -61,6 +66,11 @@ "types": "./dist/exports/all.d.ts", "default": "./dist/exports/all.js" }, + "./utilities": { + "import": "./dist/exports/utilities.js", + "types": "./dist/exports/utilities.d.ts", + "default": "./dist/exports/utilities.js" + }, "./languages/*": { "import": "./dist/languages/*.js", "types": "./dist/languages/*.d.ts", diff --git a/packages/translations/scripts/translateNewKeys/index.ts b/packages/translations/scripts/translateNewKeys/index.ts index 365bf0e64..1cdc2ae4f 100644 --- a/packages/translations/scripts/translateNewKeys/index.ts +++ b/packages/translations/scripts/translateNewKeys/index.ts @@ -10,8 +10,7 @@ import type { GenericTranslationsObject, } from '../../src/types.js' -import { cloneDeep } from '../../src/utilities/cloneDeep.js' -import { deepMerge } from '../../src/utilities/deepMerge.js' +import { deepMergeSimple } from '../../src/utilities/deepMergeSimple.js' import { acceptedLanguages } from '../../src/utilities/languages.js' import { applyEslintFixes } from './applyEslintFixes.js' import { findMissingKeys } from './findMissingKeys.js' @@ -83,7 +82,7 @@ export async function translateObject(props: { dateFNSKey: string translations: GenericTranslationsObject } - } = cloneDeep(allTranslationsObject) + } = JSON.parse(JSON.stringify(allTranslationsObject)) const allOnlyNewTranslatedTranslationsObject: GenericLanguages = {} const translationPromises: Promise[] = [] @@ -160,7 +159,7 @@ export async function translateObject(props: { targetObj[keys[keys.length - 1]] = translated allTranslatedTranslationsObject[targetLang].translations = sortKeys( - deepMerge( + deepMergeSimple( allTranslatedTranslationsObject[targetLang].translations, allOnlyNewTranslatedTranslationsObject[targetLang], ), diff --git a/packages/translations/src/exports/utilities.ts b/packages/translations/src/exports/utilities.ts new file mode 100644 index 000000000..8dde2a4cc --- /dev/null +++ b/packages/translations/src/exports/utilities.ts @@ -0,0 +1 @@ +export { deepMergeSimple } from '../utilities/deepMergeSimple.js' diff --git a/packages/translations/src/utilities/cloneDeep.ts b/packages/translations/src/utilities/cloneDeep.ts deleted file mode 100644 index b802b13af..000000000 --- a/packages/translations/src/utilities/cloneDeep.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function cloneDeep(object: T, cache: WeakMap = new WeakMap()): T { - if (object === null) return null - - if (cache.has(object)) { - return cache.get(object) - } - - // Handle Date - if (object instanceof Date) { - return new Date(object.getTime()) as unknown as T - } - - // Handle RegExp - if (object instanceof RegExp) { - return new RegExp(object.source, object.flags) as unknown as T - } - - // Handle Map - if (object instanceof Map) { - const clonedMap = new Map() - cache.set(object, clonedMap) - for (const [key, value] of object.entries()) { - clonedMap.set(key, cloneDeep(value, cache)) - } - return clonedMap as unknown as T - } - - // Handle Set - if (object instanceof Set) { - const clonedSet = new Set() - cache.set(object, clonedSet) - for (const value of object.values()) { - clonedSet.add(cloneDeep(value, cache)) - } - return clonedSet as unknown as T - } - - // Handle Array and Object - if (typeof object === 'object' && object !== null) { - if ('$$typeof' in object && typeof object.$$typeof === 'symbol') { - return object - } - - const clonedObject: any = Array.isArray(object) - ? [] - : Object.create(Object.getPrototypeOf(object)) - cache.set(object, clonedObject) - - for (const key in object) { - if ( - Object.prototype.hasOwnProperty.call(object, key) || - Object.getOwnPropertySymbols(object).includes(key as any) - ) { - clonedObject[key] = cloneDeep(object[key], cache) - } - } - - return clonedObject as T - } - - // Handle all other cases - return object -} diff --git a/packages/translations/src/utilities/deepMerge.ts b/packages/translations/src/utilities/deepMerge.ts deleted file mode 100644 index 2558328f8..000000000 --- a/packages/translations/src/utilities/deepMerge.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * obj2 has priority over obj1 - * - * Merges obj2 into obj1. Does not handle arrays - */ -export function deepMerge(obj1: any, obj2: any, doNotMergeInNulls = true) { - const output = { ...obj1 } - - for (const key in obj2) { - if (Object.prototype.hasOwnProperty.call(obj2, key)) { - if (doNotMergeInNulls) { - if ( - (obj2[key] === null || obj2[key] === undefined) && - obj1[key] !== null && - obj1[key] !== undefined - ) { - continue - } - } - - if (typeof obj2[key] === 'object' && obj1[key]) { - // Existing behavior for objects - output[key] = deepMerge(obj1[key], obj2[key], doNotMergeInNulls) - } else { - // Direct assignment for values - output[key] = obj2[key] - } - } - } - - return output -} diff --git a/packages/translations/src/utilities/deepMergeSimple.ts b/packages/translations/src/utilities/deepMergeSimple.ts new file mode 100644 index 000000000..6de2acfdb --- /dev/null +++ b/packages/translations/src/utilities/deepMergeSimple.ts @@ -0,0 +1,25 @@ +/** + * Very simple, but fast deepMerge implementation. Only deepMerges objects, not arrays and clones everything. + * Do not use this if your object contains any complex objects like React Components, or if you would like to combine Arrays. + * If you only have simple objects and need a fast deepMerge, this is the function for you. + * + * obj2 takes precedence over obj1 - thus if obj2 has a key that obj1 also has, obj2's value will be used. + * + * @param obj1 base object + * @param obj2 object to merge "into" obj1 + */ +export function deepMergeSimple(obj1: object, obj2: object): T { + const output = { ...obj1 } + + for (const key in obj2) { + if (Object.prototype.hasOwnProperty.call(obj2, key)) { + if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) { + output[key] = deepMergeSimple(obj1[key], obj2[key]) + } else { + output[key] = obj2[key] + } + } + } + + return output as T +} diff --git a/packages/translations/src/utilities/init.ts b/packages/translations/src/utilities/init.ts index 59c8ba6ea..128386b6b 100644 --- a/packages/translations/src/utilities/init.ts +++ b/packages/translations/src/utilities/init.ts @@ -8,7 +8,7 @@ import type { } from '../types.js' import { importDateFNSLocale } from '../importDateFNSLocale.js' -import { deepMerge } from './deepMerge.js' +import { deepMergeSimple } from './deepMergeSimple.js' import { getTranslationsByContext } from './getTranslationsByContext.js' /** @@ -143,7 +143,9 @@ export function t< const initTFunction: InitTFunction = (args) => { const { config, language, translations } = args - const mergedTranslations = deepMerge(translations, config?.translations?.[language] ?? {}) + const mergedTranslations = config?.translations?.[language] + ? deepMergeSimple(translations, config?.translations?.[language]) + : translations return { t: (key, vars) => { diff --git a/packages/ui/src/elements/withMergedProps/index.tsx b/packages/ui/src/elements/withMergedProps/index.tsx index 08ca1673f..82a51ba30 100644 --- a/packages/ui/src/elements/withMergedProps/index.tsx +++ b/packages/ui/src/elements/withMergedProps/index.tsx @@ -1,4 +1,4 @@ -import { deepMerge, isReactServerComponentOrFunction, serverProps } from 'payload/shared' +import { isReactServerComponentOrFunction, serverProps } from 'payload/shared' import React from 'react' /** @@ -37,7 +37,7 @@ export function withMergedProps({ } // A wrapper around the args.Component to inject the args.toMergeArgs as props, which are merged with the passed props const MergedPropsComponent: React.FC = (passedProps) => { - const mergedProps = deepMerge(passedProps, toMergeIntoProps) + const mergedProps = simpleMergeProps(passedProps, toMergeIntoProps) as CompleteReturnProps if (sanitizeServerOnlyProps) { serverProps.forEach((prop) => { @@ -50,3 +50,7 @@ export function withMergedProps({ return MergedPropsComponent } + +function simpleMergeProps(props, toMerge) { + return { ...props, ...toMerge } +} diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts index 68f7a271f..08bd56813 100644 --- a/packages/ui/src/forms/Form/fieldReducer.ts +++ b/packages/ui/src/forms/Form/fieldReducer.ts @@ -2,7 +2,7 @@ import type { FormField, FormState, Row } from 'payload' import ObjectIdImport from 'bson-objectid' import { dequal } from 'dequal/lite' // lite: no need for Map and Set support -import { deepCopyObject } from 'payload/shared' +import { deepCopyObject, deepCopyObjectSimple } from 'payload/shared' import type { FieldAction } from './types.js' @@ -249,11 +249,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { const { remainingFields, rows } = separateRows(path, state) const rowsMetadata = [...(state[path].rows || [])] - const duplicateRowMetadata = deepCopyObject(rowsMetadata[rowIndex]) + const duplicateRowMetadata = deepCopyObjectSimple(rowsMetadata[rowIndex]) if (duplicateRowMetadata.id) duplicateRowMetadata.id = new ObjectId().toHexString() const duplicateRowState = deepCopyObject(rows[rowIndex]) - if (duplicateRowState.id) duplicateRowState.id = new ObjectId().toHexString() + if (duplicateRowState.id) { + duplicateRowState.id.value = new ObjectId().toHexString() + duplicateRowState.id.initialValue = new ObjectId().toHexString() + } // If there are subfields if (Object.keys(duplicateRowState).length > 0) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6af0a224..e0e041f06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,9 +258,6 @@ importers: bson-objectid: specifier: 2.0.4 version: 2.0.4 - deepmerge: - specifier: 4.3.1 - version: 4.3.1 http-status: specifier: 1.6.2 version: 1.6.2 @@ -868,9 +865,6 @@ importers: '@payloadcms/ui': specifier: workspace:* version: link:../ui - deepmerge: - specifier: ^4.2.2 - version: 4.3.1 escape-html: specifier: ^1.0.3 version: 1.0.3 @@ -951,9 +945,6 @@ importers: '@payloadcms/ui': specifier: workspace:* version: link:../ui - deepmerge: - specifier: 4.3.1 - version: 4.3.1 react: specifier: ^19.0.0-rc-6230622a1a-20240610 version: 19.0.0-rc-fb9a90fa48-20240614 diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 65d707e7a..01b2a6640 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -489,7 +489,7 @@ describe('Auth', () => { expect(lockedUser.docs[0].loginAttempts).toBe(2) expect(lockedUser.docs[0].lockUntil).toBeDefined() - const manuallyReleaseLock = new Date(Date.now() - 605 * 1000) + const manuallyReleaseLock = new Date(Date.now() - 605 * 1000).toISOString() const userLockElapsed = await payload.update({ collection: slug, data: { @@ -503,7 +503,7 @@ describe('Auth', () => { }, }) - expect(userLockElapsed.docs[0].lockUntil).toEqual(manuallyReleaseLock.toISOString()) + expect(userLockElapsed.docs[0].lockUntil).toEqual(manuallyReleaseLock) // login await restClient.POST(`/${slug}/login`, { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index c6141e488..3ad158c9f 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -286,7 +286,7 @@ describe('Localization', () => { const post = await payload.create({ collection: localizedSortSlug, data: { - date: new Date(), + date: new Date().toISOString(), title: `EN ${i}`, }, locale: englishLocale, @@ -296,7 +296,7 @@ describe('Localization', () => { id: post.id, collection: localizedSortSlug, data: { - date: new Date(), + date: new Date().toISOString(), title: `ES ${i}`, }, locale: spanishLocale,