Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@
"scheduler": "0.25.0",
"sonner": "^1.7.2",
"ts-essentials": "10.0.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0"
},
"devDependencies": {
Expand Down
38 changes: 28 additions & 10 deletions packages/ui/src/forms/Form/context.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
'use client'
import type { RenderedField } from 'payload'

import { createContext, use } from 'react'
import {
createContext as createSelectorContext,
useContextSelector,
useContext as useFullContext,
} from 'use-context-selector'
import { createContext, use, useSyncExternalStore } from 'react'

import type { Context, FormFieldsContext as FormFieldsContextType } from './types.js'

Expand All @@ -22,7 +17,14 @@ const ProcessingContext = createContext(false)
const BackgroundProcessingContext = createContext(false)
const ModifiedContext = createContext(false)
const InitializingContext = createContext(false)
const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null])

// Create a store for FormFields that supports selective subscriptions
type Store<Snapshot = FormFieldsContextType> = {
getState: () => Snapshot
subscribe: (listener: () => void) => () => void
}

const FormFieldsStoreContext = createContext<null | Store>(null)

export type RenderedFieldSlots = Map<string, RenderedField>

Expand Down Expand Up @@ -56,20 +58,35 @@ const useFormInitializing = (): boolean => use(InitializingContext)
*/
const useFormFields = <Value = unknown>(
selector: (context: FormFieldsContextType) => Value,
): Value => useContextSelector(FormFieldsContext, selector)
): Value => {
const store = use(FormFieldsStoreContext)
if (!store) {
throw new Error('useFormFields must be used within a Form component')
}

const getSnapshot = () => selector(store.getState())
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
}

/**
* Get the state of all form fields.
*
* @see https://payloadcms.com/docs/admin/react-hooks#useallformfields
*/
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
const useAllFormFields = (): FormFieldsContextType => {
const store = use(FormFieldsStoreContext)
if (!store) {
throw new Error('useAllFormFields must be used within a Form component')
}

return store.getState()
}

export {
BackgroundProcessingContext,
DocumentFormContext,
FormContext,
FormFieldsContext,
FormFieldsStoreContext,
FormWatchContext,
InitializingContext,
ModifiedContext,
Expand All @@ -86,3 +103,4 @@ export {
useFormSubmitted,
useWatchForm,
}
export type { Store as FormFieldsStore }
47 changes: 38 additions & 9 deletions packages/ui/src/forms/Form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { toast } from 'sonner'

import type { FormFieldsStore } from './context.js'
import type {
CreateFormData,
Context as FormContextType,
Expand Down Expand Up @@ -42,7 +43,7 @@ import {
BackgroundProcessingContext,
DocumentFormContext,
FormContext,
FormFieldsContext,
FormFieldsStoreContext,
FormWatchContext,
InitializingContext,
ModifiedContext,
Expand Down Expand Up @@ -126,14 +127,43 @@ export const Form: React.FC<FormProps> = (props) => {
const abortResetFormRef = useRef<AbortController>(null)
const isFirstRenderRef = useRef(true)

const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)

const [formState, dispatchFields] = fieldsReducer

const [formState, dispatchFields] = useReducer(fieldReducer, initialState)
contextRef.current.fields = formState

const prevFormState = useRef(formState)

// Create refs to hold the current state and dispatch
const formStateRef = useRef(formState)
const dispatchFieldsRef = useRef(dispatchFields)

// Update refs when state changes
formStateRef.current = formState
dispatchFieldsRef.current = dispatchFields

// Create a stable store for selective subscriptions using useSyncExternalStore
const listenersRef = useRef(new Set<() => void>())
const formFieldsStore = React.useMemo<FormFieldsStore>(() => {
return {
getState: () => [formStateRef.current, dispatchFieldsRef.current],
subscribe: (listener) => {
listenersRef.current.add(listener)
return () => {
listenersRef.current.delete(listener)
}
},
}
}, [])

// Notify subscribers when formState changes
const prevFormStateForStore = useRef(formState)
React.useEffect(() => {
if (prevFormStateForStore.current !== formState) {
prevFormStateForStore.current = formState
// Notify all subscribers
listenersRef.current.forEach((listener) => listener())
}
}, [formState])

const validateForm = useCallback(async () => {
const validatedFieldState = {}
let isValid = true
Expand Down Expand Up @@ -836,10 +866,9 @@ export const Form: React.FC<FormProps> = (props) => {
<ProcessingContext value={processing}>
<BackgroundProcessingContext value={backgroundProcessing}>
<ModifiedContext value={modified}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
<FormFieldsContext.Provider value={fieldsReducer}>
<FormFieldsStoreContext value={formFieldsStore}>
{children}
</FormFieldsContext.Provider>
</FormFieldsStoreContext>
</ModifiedContext>
</BackgroundProcessingContext>
</ProcessingContext>
Expand All @@ -855,7 +884,7 @@ export const Form: React.FC<FormProps> = (props) => {
export {
DocumentFormContext,
FormContext,
FormFieldsContext,
FormFieldsStoreContext,
FormWatchContext,
ModifiedContext,
ProcessingContext,
Expand Down
14 changes: 0 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 2 additions & 16 deletions test/_community/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,21 +126,7 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
customField?: string | null;
updatedAt: string;
createdAt: string;
}
Expand Down Expand Up @@ -279,7 +265,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
content?: T;
customField?: T;
updatedAt?: T;
createdAt?: T;
}
Expand Down
18 changes: 17 additions & 1 deletion test/fields/collections/UI/UICustomClient.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable no-console */
'use client'
import type { TextFieldClientComponent } from 'payload'

import React from 'react'
import React, { useLayoutEffect } from 'react'

export const UICustomClient: TextFieldClientComponent = ({
field: {
Expand All @@ -11,3 +12,18 @@ export const UICustomClient: TextFieldClientComponent = ({
}) => {
return <div id={name}>{custom?.customValue}</div>
}

import { TextField, useFormFields } from '@payloadcms/ui'

export const UICustomField: TextFieldClientComponent = (props) => {
// to test useFormFields
const uiCustomField = useFormFields(([fields]) => fields.uiCustomField)

useLayoutEffect(() => {
console.log(`UICustomField changed`)
}, [uiCustomField])

console.log('UICustomField rendered')

return <TextField {...props} />
}
31 changes: 31 additions & 0 deletions test/fields/collections/UI/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,35 @@ describe('Radio', () => {
await expect(uiField).toBeVisible()
await expect(uiField).toContainText('client-side-configuration')
})

test('The entire Form should not re-render when a field changes', async () => {
await page.goto(url.create)

const logs: string[] = []

await page.waitForLoadState()
// We don't want to save the logs from the initial render
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000)

page.on('console', (msg) => {
logs.push(msg.text())
})

await page.locator('#field-text').fill('test')

await expect(() => {
expect(logs).toHaveLength(0)
}).toPass({ timeout: 500 })

await page.locator('#field-uiCustomField').fill('test')

await expect(() => {
expect(logs).toContain('UICustomField changed')
expect(logs).toContain('UICustomField rendered')
// It should be 3, but I leave a margin of error for contingencies
// like React strict mode, to make the test less flaky.
expect(logs.length).toBeLessThanOrEqual(4)
}).toPass({ timeout: 500 })
})
})
9 changes: 9 additions & 0 deletions test/fields/collections/UI/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const UIFields: CollectionConfig = {
type: 'text',
required: true,
},
{
name: 'uiCustomField',
type: 'ui',
admin: {
components: {
Field: '/collections/UI/UICustomClient.js#UICustomField',
},
},
},
{
type: 'ui',
name: 'uiCustomClient',
Expand Down
Loading