Compare commits

...

3 Commits

Author SHA1 Message Date
Jacob Fletcher
6124e26468 Merge branch 'main' into perf/field-store 2025-05-16 15:31:40 -04:00
Jacob Fletcher
5342d303ea poc 2025-05-15 17:36:26 -04:00
Jacob Fletcher
766236f38e wip 2025-05-15 17:36:23 -04:00
6 changed files with 206 additions and 60 deletions

View File

@@ -136,8 +136,8 @@
"scheduler": "0.25.0",
"sonner": "^1.7.2",
"ts-essentials": "10.0.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0"
"uuid": "10.0.0",
"zustand": "5.0.4"
},
"devDependencies": {
"@babel/cli": "7.26.4",

View File

@@ -2,11 +2,11 @@
import type { RenderedField } from 'payload'
import { createContext, use } from 'react'
import {
createContext as createSelectorContext,
useContextSelector,
useContext as useFullContext,
} from 'use-context-selector'
// import {
// createContext as createSelectorContext,
// useContextSelector,
// useContext as useFullContext,
// } from 'use-context-selector'
import type { Context, FormFieldsContext as FormFieldsContextType } from './types.js'
@@ -22,7 +22,7 @@ const ProcessingContext = createContext(false)
const BackgroundProcessingContext = createContext(false)
const ModifiedContext = createContext(false)
const InitializingContext = createContext(false)
const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null])
// const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null])
export type RenderedFieldSlots = Map<string, RenderedField>
@@ -56,20 +56,20 @@ const useFormInitializing = (): boolean => use(InitializingContext)
*/
const useFormFields = <Value = unknown>(
selector: (context: FormFieldsContextType) => Value,
): Value => useContextSelector(FormFieldsContext, selector)
): Value => {}
/**
* Get the state of all form fields.
*
* @see https://payloadcms.com/docs/admin/react-hooks#useallformfields
*/
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
const useAllFormFields = (): FormFieldsContextType => {}
export {
BackgroundProcessingContext,
DocumentFormContext,
FormContext,
FormFieldsContext,
// FormFieldsContext,
FormWatchContext,
InitializingContext,
ModifiedContext,

View File

@@ -10,7 +10,7 @@ import {
reduceFieldsToValues,
wait,
} from 'payload/shared'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import type {
@@ -41,8 +41,6 @@ import {
BackgroundProcessingContext,
DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
InitializingContext,
ModifiedContext,
ProcessingContext,
@@ -50,12 +48,12 @@ import {
useDocumentForm,
} from './context.js'
import { errorMessages } from './errorMessages.js'
import { fieldReducer } from './fieldReducer.js'
import { initContextState } from './initContextState.js'
import { FormStateStoreContext, useCreateFormStateStore, useFormStateStore } from './store.js'
const baseClass = 'form'
export const Form: React.FC<FormProps> = (props) => {
const FormComponent: React.FC<FormProps> = (props) => {
const { id, collectionSlug, docConfig, docPermissions, getDocPreferences, globalSlug } =
useDocumentInfo()
@@ -119,9 +117,11 @@ export const Form: React.FC<FormProps> = (props) => {
const abortResetFormRef = useRef<AbortController>(null)
const isFirstRenderRef = useRef(true)
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
const store = useFormStateStore((store) => store)
const [formState, dispatchFields] = fieldsReducer
const formState = useMemo(() => store.state, [store])
const dispatchFields = useMemo(() => store.dispatchFields, [store])
contextRef.current.fields = formState
@@ -190,13 +190,13 @@ export const Form: React.FC<FormProps> = (props) => {
await Promise.all(validationPromises)
if (!dequal(contextRef.current.fields, validatedFieldState)) {
dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState })
store.dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState })
}
setIsValid(isValid)
return isValid
}, [collectionSlug, config, dispatchFields, id, operation, t, user, documentForm])
}, [collectionSlug, config, store, id, operation, t, user, documentForm])
const submit = useCallback(
async (options: SubmitOptions = {}, e): Promise<void> => {
@@ -797,37 +797,35 @@ export const Form: React.FC<FormProps> = (props) => {
>
<DocumentFormContextComponent {...documentFormContextProps}>
<FormContext value={contextRef.current}>
<FormWatchContext
value={{
fields: formState,
...contextRef.current,
}}
>
<SubmittedContext value={submitted}>
<InitializingContext value={!isMounted || (isMounted && initializing)}>
<ProcessingContext value={processing}>
<BackgroundProcessingContext value={backgroundProcessing}>
<ModifiedContext value={modified}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext>
</BackgroundProcessingContext>
</ProcessingContext>
</InitializingContext>
</SubmittedContext>
</FormWatchContext>
<SubmittedContext value={submitted}>
<InitializingContext value={!isMounted || (isMounted && initializing)}>
<ProcessingContext value={processing}>
<BackgroundProcessingContext value={backgroundProcessing}>
<ModifiedContext value={modified}>{children}</ModifiedContext>
</BackgroundProcessingContext>
</ProcessingContext>
</InitializingContext>
</SubmittedContext>
</FormContext>
</DocumentFormContextComponent>
</El>
)
}
export const Form = (props) => {
const formStateStore = useCreateFormStateStore(props.initialState)
return (
<FormStateStoreContext value={formStateStore}>
<FormComponent {...props} />
</FormStateStoreContext>
)
}
export {
DocumentFormContext,
FormContext,
FormFieldsContext,
// FormFieldsContext,
FormWatchContext,
ModifiedContext,
ProcessingContext,

View File

@@ -0,0 +1,132 @@
import type { FormState } from 'payload'
import type { StoreApi } from 'zustand'
import { createContext, useContext, useRef } from 'react'
import { createStore, useStore } from 'zustand'
import { fieldReducer } from './fieldReducer.js'
// export const useFormFields = create((set, initialState) => ({
// fields: {} as FormState,
// } satisfies FormState))
export type FormStateStore = {
dispatchFields: (action: any) => void
state: FormState
}
export const FormStateStoreContext = createContext<null | StoreApi<FormStateStore>>(null)
export const createFormStateStore = (initialState: FormState) =>
createStore<FormStateStore>()((set) => ({
dispatchFields: (action) =>
set((store) => ({ ...store, state: fieldReducer(store.state, action) })),
state: initialState,
}))
export const useCreateFormStateStore = (initialState: FormState) => {
const storeRef = useRef<null | StoreApi<FormStateStore>>(null)
if (storeRef.current === null) {
storeRef.current = createFormStateStore(initialState)
}
return storeRef.current
}
export const useFormStateStore = <T>(selector: (store: FormStateStore) => T): T => {
const formStateStoreContext = useContext(FormStateStoreContext)
if (!formStateStoreContext) {
throw new Error(`useCounterStore must be used within CounterStoreProvider`)
}
return useStore(formStateStoreContext, selector)
}
// import { randomUUID } from 'crypto'
// import type { FormState } from 'payload'
// type Listener = () => void
// type FieldSubscriber = {
// callback: Listener
// path: string
// }
// type FormStateStore = {
// getSnapshot: (path: string) => any
// setField: (path: string, value: any) => void
// setFields: (values: FormState) => void
// subscribe: (path: string, callback: Listener) => () => void
// }
// function create(initialState: FormState = {}): FormStateStore {
// let state = { ...initialState }
// const subscribers = new Set<FieldSubscriber>()
// const subscribe = (path: string, callback: Listener) => {
// const subscriber = { callback, path }
// subscribers.add(subscriber)
// return () => {
// subscribers.delete(subscriber)
// }
// }
// const getSnapshot = (path: string) => {
// return state[path]
// }
// const setField = (path: string, value: any) => {
// if (state[path] !== value) {
// state = { ...state, [path]: value }
// // Notify only subscribers of this specific field
// subscribers.forEach((subscriber) => {
// if (subscriber.path === path) {
// subscriber.callback()
// }
// })
// }
// }
// const setFields = (values: FormState) => {
// const changedFields = new Set<string>()
// // Track which fields actually changed
// Object.entries(values).forEach(([path, value]) => {
// if (state[path] !== value) {
// changedFields.add(path)
// }
// })
// if (changedFields.size > 0) {
// state = { ...state, ...values }
// // Notify only subscribers of changed fields
// subscribers.forEach((subscriber) => {
// if (changedFields.has(subscriber.path)) {
// subscriber.callback()
// }
// })
// }
// }
// return {
// getSnapshot,
// setField,
// setFields,
// subscribe,
// }
// }
// const globalStore = new Map<string, FormStateStore>()
// export const createFormStateStore = (initialState) => {
// const id = randomUUID()
// const formStateStore = create(initialState)
// globalStore.set(id, formStateStore)
// }
// export { formStateStore }

View File

@@ -17,12 +17,12 @@ import { useTranslation } from '../../providers/Translation/index.js'
import {
useDocumentForm,
useForm,
useFormFields,
useFormInitializing,
useFormModified,
useFormProcessing,
useFormSubmitted,
} from '../Form/context.js'
import { useFormStateStore } from '../Form/store.js'
import { useFieldPath } from '../RenderFields/context.js'
/**
@@ -52,8 +52,11 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const { id, collectionSlug } = useDocumentInfo()
const operation = useOperation()
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
const field = useFormFields(([fields]) => (fields && fields?.[path]) || null)
const field = useFormStateStore((store) => store.state[path])
const value = field?.value
const dispatchField = useFormStateStore((store) => store.dispatchFields)
const { t } = useTranslation()
const { config } = useConfig()
@@ -63,7 +66,6 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const modified = useFormModified()
const filterOptions = field?.filterOptions
const value = field?.value as TValue
const initialValue = field?.initialValue as TValue
const valid = typeof field?.valid === 'boolean' ? field.valid : true
const showError = valid === false && submitted

42
pnpm-lock.yaml generated
View File

@@ -1621,12 +1621,12 @@ importers:
ts-essentials:
specifier: 10.0.3
version: 10.0.3(typescript@5.7.3)
use-context-selector:
specifier: 2.0.0
version: 2.0.0(react@19.1.0)(scheduler@0.25.0)
uuid:
specifier: 10.0.0
version: 10.0.0
zustand:
specifier: 5.0.4
version: 5.0.4(@types/react@19.1.0)(immer@9.0.21)(react@19.1.0)
devDependencies:
'@babel/cli':
specifier: 7.26.4
@@ -8594,6 +8594,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
@@ -10282,12 +10283,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-context-selector@2.0.0:
resolution: {integrity: sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==}
peerDependencies:
react: 19.1.0
scheduler: '>=0.19.0'
use-isomorphic-layout-effect@1.2.0:
resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==}
peerDependencies:
@@ -10538,6 +10533,24 @@ packages:
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
zustand@5.0.4:
resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: 19.1.0
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -20564,11 +20577,6 @@ snapshots:
dependencies:
punycode: 2.3.1
use-context-selector@2.0.0(react@19.1.0)(scheduler@0.25.0):
dependencies:
react: 19.1.0
scheduler: 0.25.0
use-isomorphic-layout-effect@1.2.0(@types/react@19.1.0)(react@19.1.0):
dependencies:
react: 19.1.0
@@ -20827,4 +20835,10 @@ snapshots:
zod@3.23.8: {}
zustand@5.0.4(@types/react@19.1.0)(immer@9.0.21)(react@19.1.0):
optionalDependencies:
'@types/react': 19.1.0
immer: 9.0.21
react: 19.1.0
zwitch@2.0.4: {}