fix(ui): improve useIgnoredEffect hook (#10961)

The `useIgnoredEffect` hook is useful in firing an effect only when a _subset_ of dependencies change, despite subscribing to many dependencies. But the previous implementation of `useIgnoredEffect` had a few problems:

- The effect did not receive the updated values of `ignoredDeps` - thus, `useIgnoredEffect` pretty much worked the same way as using `useEffect` and omitting said dependencies from the dependency array. This caused the `ignoredDeps` values to be stale.
- It compared objects by value instead of reference, which is slower and behaves differently than `useEffect` itself.
- Edge cases where the effect does not run even though the dependencies have changed. E.g. if an `ignoredDep` has value `null` and a `dep` changes its value from _something_ to `null`, the effect incorrectly does **not** run, as the current logic detects that said value is part of `ignoredDeps` => no `dep` actually changed.

This PR replaces the `useIgnoredEffect` hook with a new pattern which to combine `useEffect` with a new `useEffectEvent` hook as described here: https://react.dev/learn/separating-events-from-effects#extracting-non-reactive-logic-out-of-effects. While this is not available in React 19 stable, there is a polyfill available that's already used in several big projects (e.g. react-spectrum and bluesky).
This commit is contained in:
Alessio Gravili
2025-02-06 11:37:49 -07:00
committed by GitHub
parent 824f9a7f4d
commit 8ed410456c
10 changed files with 372 additions and 432 deletions

View File

@@ -37,7 +37,7 @@
"eslint-plugin-jest-dom": "5.4.0", "eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1", "eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0", "eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0", "globals": "15.12.0",
"typescript": "5.7.3", "typescript": "5.7.3",

View File

@@ -36,7 +36,7 @@
"eslint-plugin-jest-dom": "5.4.0", "eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1", "eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0", "eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0", "globals": "15.12.0",
"typescript": "5.7.3", "typescript": "5.7.3",

View File

@@ -3,7 +3,7 @@
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import { versionDefaults } from 'payload/shared' import { versionDefaults } from 'payload/shared'
import React, { useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -13,15 +13,15 @@ import {
useFormSubmitted, useFormSubmitted,
} from '../../forms/Form/context.js' } from '../../forms/Form/context.js'
import { useDebounce } from '../../hooks/useDebounce.js' import { useDebounce } from '../../hooks/useDebounce.js'
import { useIgnoredEffect } from '../../hooks/useIgnoredEffect.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useDocumentEvents } from '../../providers/DocumentEvents/index.js' import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
import { formatTimeToNow } from '../../utilities/formatDate.js' import { formatTimeToNow } from '../../utilities/formatDate.js'
import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js' import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js'
import './index.scss'
const baseClass = 'autosave' const baseClass = 'autosave'
// The minimum time the saving state should be shown // The minimum time the saving state should be shown
@@ -111,194 +111,174 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
} }
}, []) }, [])
// When debounced fields change, autosave const handleAutosave = useEffectEvent(() => {
useIgnoredEffect( const abortController = new AbortController()
() => { let autosaveTimeout = undefined
const abortController = new AbortController() // We need to log the time in order to figure out if we need to trigger the state off later
let autosaveTimeout = undefined let startTimestamp = undefined
// We need to log the time in order to figure out if we need to trigger the state off later let endTimestamp = undefined
let startTimestamp = undefined
let endTimestamp = undefined
const autosave = async () => { const autosave = async () => {
if (modified) { if (modified) {
startTimestamp = new Date().getTime() startTimestamp = new Date().getTime()
setSaving(true) setSaving(true)
let url: string let url: string
let method: string let method: string
let entitySlug: string let entitySlug: string
if (collection && id) { if (collection && id) {
entitySlug = collection.slug entitySlug = collection.slug
url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}` url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH' method = 'PATCH'
} }
if (globalDoc) { if (globalDoc) {
entitySlug = globalDoc.slug entitySlug = globalDoc.slug
url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}` url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST' method = 'POST'
} }
if (url) { if (url) {
if (modifiedRef.current) { if (modifiedRef.current) {
const { data, valid } = { const { data, valid } = {
...reduceFieldsToValuesWithValidation(fieldRef.current, true), ...reduceFieldsToValuesWithValidation(fieldRef.current, true),
} }
data._status = 'draft' data._status = 'draft'
const skipSubmission = const skipSubmission =
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission) { if (!skipSubmission) {
await fetch(url, { await fetch(url, {
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept-Language': i18n.language, 'Accept-Language': i18n.language,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
method, method,
signal: abortController.signal, signal: abortController.signal,
})
.then((res) => {
const newDate = new Date()
// We need to log the time in order to figure out if we need to trigger the state off later
endTimestamp = newDate.getTime()
if (res.status === 200) {
setLastUpdateTime(newDate.getTime())
reportUpdate({
id,
entitySlug,
updatedAt: newDate.toISOString(),
})
if (!mostRecentVersionIsAutosaved) {
incrementVersionCount()
setMostRecentVersionIsAutosaved(true)
setUnpublishedVersionCount((prev) => prev + 1)
}
}
return res.json()
}) })
.then((res) => { .then((json) => {
const newDate = new Date() if (versionsConfig?.drafts && versionsConfig?.drafts?.validate && json?.errors) {
// We need to log the time in order to figure out if we need to trigger the state off later if (Array.isArray(json.errors)) {
endTimestamp = newDate.getTime() const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs = []
const newNonFieldErrs = []
if (res.status === 200) { if (err?.message) {
setLastUpdateTime(newDate.getTime()) newNonFieldErrs.push(err)
}
reportUpdate({ if (Array.isArray(err?.data)) {
id, err.data.forEach((dataError) => {
entitySlug, if (dataError?.field) {
updatedAt: newDate.toISOString(), newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
dispatchFields({
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
}) })
if (!mostRecentVersionIsAutosaved) { nonFieldErrors.forEach((err) => {
incrementVersionCount() toast.error(err.message || i18n.t('error:unknown'))
setMostRecentVersionIsAutosaved(true) })
setUnpublishedVersionCount((prev) => prev + 1)
}
}
return res.json() setSubmitted(true)
})
.then((json) => {
if (
versionsConfig?.drafts &&
versionsConfig?.drafts?.validate &&
json?.errors
) {
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs = []
const newNonFieldErrs = []
if (err?.message) {
newNonFieldErrs.push(err)
}
if (Array.isArray(err?.data)) {
err.data.forEach((dataError) => {
if (dataError?.field) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
dispatchFields({
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
})
nonFieldErrors.forEach((err) => {
toast.error(err.message || i18n.t('error:unknown'))
})
setSubmitted(true)
setSaving(false)
return
}
} else {
// If it's not an error then we can update the document data inside the context
const document = json?.doc || json?.result
// Manually update the data since this function doesn't fire the `submit` function from useForm
if (document) {
updateSavedDocumentData(document)
}
}
})
.then(() => {
// If request was faster than minimum animation time, animate the difference
if (endTimestamp - startTimestamp < minimumAnimationTime) {
autosaveTimeout = setTimeout(
() => {
setSaving(false)
},
minimumAnimationTime - (endTimestamp - startTimestamp),
)
} else {
setSaving(false) setSaving(false)
return
} }
}) } else {
} // If it's not an error then we can update the document data inside the context
const document = json?.doc || json?.result
// Manually update the data since this function doesn't fire the `submit` function from useForm
if (document) {
updateSavedDocumentData(document)
}
}
})
.then(() => {
// If request was faster than minimum animation time, animate the difference
if (endTimestamp - startTimestamp < minimumAnimationTime) {
autosaveTimeout = setTimeout(
() => {
setSaving(false)
},
minimumAnimationTime - (endTimestamp - startTimestamp),
)
} else {
setSaving(false)
}
})
} }
} }
} }
} }
}
queueRef.current.push(autosave) queueRef.current.push(autosave)
void processQueue() void processQueue()
return () => { return { abortController, autosaveTimeout }
if (autosaveTimeout) { })
clearTimeout(autosaveTimeout)
} // When debounced fields change, autosave
if (abortController.signal) { useEffect(() => {
try { const { abortController, autosaveTimeout } = handleAutosave()
abortController.abort('Autosave closed early.')
} catch (error) { return () => {
// swallow error if (autosaveTimeout) {
} clearTimeout(autosaveTimeout)
}
setSaving(false)
} }
}, if (abortController.signal) {
[debouncedFields], try {
[ abortController.abort('Autosave closed early.')
api, } catch (error) {
collection, // swallow error
dispatchFields, }
globalDoc, }
i18n, setSaving(false)
id, }
interval, }, [debouncedFields])
modified,
reportUpdate,
serverURL,
setSubmitted,
versionsConfig?.drafts,
submitted,
setLastUpdateTime,
mostRecentVersionIsAutosaved,
incrementVersionCount,
setMostRecentVersionIsAutosaved,
],
)
return ( return (
<div className={baseClass}> <div className={baseClass}>

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import type { ListDrawerProps } from './types.js' import type { ListDrawerProps } from './types.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { useIgnoredEffect } from '../../hooks/useIgnoredEffect.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
@@ -59,18 +59,19 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
useDocumentDrawer({ useDocumentDrawer({
collectionSlug: selectedOption.value, collectionSlug: selectedOption.value,
}) })
useIgnoredEffect(
() => { const updateSelectedOption = useEffectEvent((selectedCollectionFromProps: string) => {
if (selectedCollectionFromProps && selectedCollectionFromProps !== selectedOption?.value) { if (selectedCollectionFromProps && selectedCollectionFromProps !== selectedOption?.value) {
setSelectedOption({ setSelectedOption({
label: getEntityConfig({ collectionSlug: selectedCollectionFromProps })?.labels, label: getEntityConfig({ collectionSlug: selectedCollectionFromProps })?.labels,
value: selectedCollectionFromProps, value: selectedCollectionFromProps,
}) })
} }
}, })
[selectedCollectionFromProps],
[collections, selectedOption], useEffect(() => {
) updateSelectedOption(selectedCollectionFromProps)
}, [selectedCollectionFromProps])
const renderList = useCallback( const renderList = useCallback(
async (slug: string, query?: ListQuery) => { async (slug: string, query?: ListQuery) => {

View File

@@ -1,21 +1,15 @@
'use client' 'use client'
import type { import type { JoinFieldClient, ListQuery, PaginatedDocs, Where } from 'payload'
ClientCollectionConfig,
JoinFieldClient,
ListQuery,
PaginatedDocs,
Where,
} from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useState } from 'react' import React, { Fragment, useCallback, useEffect, useState } from 'react'
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
import type { Column } from '../Table/index.js' import type { Column } from '../Table/index.js'
import { Button } from '../../elements/Button/index.js' import { Button } from '../../elements/Button/index.js'
import { Pill } from '../../elements/Pill/index.js' import { Pill } from '../../elements/Pill/index.js'
import { useIgnoredEffect } from '../../hooks/useIgnoredEffect.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { ChevronIcon } from '../../icons/Chevron/index.js' import { ChevronIcon } from '../../icons/Chevron/index.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
@@ -25,10 +19,10 @@ import { useTranslation } from '../../providers/Translation/index.js'
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
import { AnimateHeight } from '../AnimateHeight/index.js' import { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js' import { ColumnSelector } from '../ColumnSelector/index.js'
import './index.scss'
import { useDocumentDrawer } from '../DocumentDrawer/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
import { TableColumnsProvider } from '../TableColumns/index.js' import { TableColumnsProvider } from '../TableColumns/index.js'
import './index.scss'
import { DrawerLink } from './cells/DrawerLink/index.js' import { DrawerLink } from './cells/DrawerLink/index.js'
import { RelationshipTablePagination } from './Pagination.js' import { RelationshipTablePagination } from './Pagination.js'
@@ -152,15 +146,15 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
], ],
) )
useIgnoredEffect( const handleTableRender = useEffectEvent((query: ListQuery, disableTable: boolean) => {
() => { if (!disableTable && (!Table || query)) {
if (!disableTable && (!Table || query)) { void renderTable()
void renderTable() }
} })
},
[query, disableTable], useEffect(() => {
[Table, renderTable], handleTableRender(query, disableTable)
) }, [query, disableTable])
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, openDrawer }] = useDocumentDrawer({ const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, openDrawer }] = useDocumentDrawer({
collectionSlug: relationTo, collectionSlug: relationTo,

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import type { PaginatedDocs, RelationshipFieldClientComponent, Where } from 'payload' import type { PaginatedDocs, RelationshipFieldClientComponent, Where } from 'payload'
import { dequal } from 'dequal/lite'
import { wordBoundariesRegex } from 'payload/shared' import { wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm' import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
@@ -19,11 +20,12 @@ import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useField } from '../../forms/useField/index.js' import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js' import { withCondition } from '../../forms/withCondition/index.js'
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js' import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js'
import { useIgnoredEffect } from '../../hooks/useIgnoredEffect.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
import { mergeFieldStyles } from '../mergeFieldStyles.js' import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../shared/index.js'
import { createRelationMap } from './createRelationMap.js' import { createRelationMap } from './createRelationMap.js'
@@ -31,7 +33,6 @@ import { findOptionsByValue } from './findOptionsByValue.js'
import { optionsReducer } from './optionsReducer.js' import { optionsReducer } from './optionsReducer.js'
import { MultiValueLabel } from './select-components/MultiValueLabel/index.js' import { MultiValueLabel } from './select-components/MultiValueLabel/index.js'
import { SingleValue } from './select-components/SingleValue/index.js' import { SingleValue } from './select-components/SingleValue/index.js'
import './index.scss'
const maxResultsPerRequest = 10 const maxResultsPerRequest = 10
@@ -306,89 +307,83 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
[search, updateSearch], [search, updateSearch],
) )
const handleValueChange = useEffectEvent((value: Value | Value[]) => {
const relationMap = createRelationMap({
hasMany,
relationTo,
value,
})
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
},
},
}
if (!errorLoading) {
const response = await fetch(`${serverURL}${api}/${relation}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = getEntityConfig({ collectionSlug: relation })
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
}
}
}, Promise.resolve())
})
const prevValue = useRef(value)
const isFirstRenderRef = useRef(true)
// /////////////////////////////////// // ///////////////////////////////////
// Ensure we have an option for each value // Ensure we have an option for each value
// /////////////////////////////////// // ///////////////////////////////////
useIgnoredEffect( useEffect(() => {
() => { if (isFirstRenderRef.current || !dequal(value, prevValue.current)) {
const relationMap = createRelationMap({ handleValueChange(value)
hasMany, }
relationTo, isFirstRenderRef.current = false
value, prevValue.current = value
}) }, [value])
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
},
},
}
if (!errorLoading) {
const response = await fetch(`${serverURL}${api}/${relation}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = getEntityConfig({ collectionSlug: relation })
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
}
}
}, Promise.resolve())
},
[value],
[
options,
hasMany,
errorLoading,
getEntityConfig,
hasMultipleRelations,
serverURL,
api,
i18n,
relationTo,
locale,
config,
],
)
// Determine if we should switch to word boundary search // Determine if we should switch to word boundary search
useEffect(() => { useEffect(() => {
@@ -401,39 +396,39 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
setEnableWordBoundarySearch(!isIdOnly) setEnableWordBoundarySearch(!isIdOnly)
}, [relationTo, getEntityConfig]) }, [relationTo, getEntityConfig])
const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => {
return await getResults(args)
})
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component // When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run // Note - effect should not run on first run
useIgnoredEffect( useEffect(() => {
() => { // If the menu is open while filterOptions changes
// If the menu is open while filterOptions changes // due to latency of form state and fast clicking into this field,
// due to latency of form state and fast clicking into this field, // re-fetch options
// re-fetch options if (hasLoadedFirstPageRef.current && menuIsOpen) {
if (hasLoadedFirstPageRef.current && menuIsOpen) { setIsLoading(true)
setIsLoading(true) void getResultsEffectEvent({
void getResults({ filterOptions,
filterOptions, lastLoadedPage: {},
lastLoadedPage: {}, onSuccess: () => {
onSuccess: () => { hasLoadedFirstPageRef.current = true
hasLoadedFirstPageRef.current = true setIsLoading(false)
setIsLoading(false) },
}, value: valueRef.current,
value: valueRef.current,
})
}
// If the menu is not open, still reset the field state
// because we need to get new options next time the menu opens
dispatchOptions({
type: 'CLEAR',
exemptValues: valueRef.current,
}) })
}
setLastFullyLoadedRelation(-1) // If the menu is not open, still reset the field state
setLastLoadedPage({}) // because we need to get new options next time the menu opens
}, dispatchOptions({
[relationTo, filterOptions, locale, path, menuIsOpen], type: 'CLEAR',
[getResults], exemptValues: valueRef.current,
) })
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
}, [relationTo, filterOptions, locale, path, menuIsOpen])
const onSave = useCallback<DocumentDrawerProps['onSave']>( const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => { (args) => {

View File

@@ -21,7 +21,8 @@ import type {
SubmitOptions, SubmitOptions,
} from './types.js' } from './types.js'
import { useIgnoredEffectDebounced } from '../../hooks/useIgnoredEffect.js' import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js' import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
@@ -661,42 +662,50 @@ export const Form: React.FC<FormProps> = (props) => {
const classes = [className, baseClass].filter(Boolean).join(' ') const classes = [className, baseClass].filter(Boolean).join(' ')
useIgnoredEffectDebounced( const executeOnChange = useEffectEvent(async (submitted: boolean) => {
if (Array.isArray(onChange)) {
let revalidatedFormState: FormState = contextRef.current.fields
for (const onChangeFn of onChange) {
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
revalidatedFormState = await onChangeFn({
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
submitted,
})
}
if (!revalidatedFormState) {
return
}
const { changed, newState } = mergeServerFormState({
existingState: contextRef.current.fields || {},
incomingState: revalidatedFormState,
})
if (changed) {
dispatchFields({
type: 'REPLACE_STATE',
optimize: false,
state: newState,
})
}
}
})
const prevFields = useRef(contextRef.current.fields)
const isFirstRenderRef = useRef(true)
useDebouncedEffect(
() => { () => {
const executeOnChange = async () => { if (isFirstRenderRef.current || !dequal(contextRef.current.fields, prevFields.current)) {
if (Array.isArray(onChange)) { if (modified) {
let revalidatedFormState: FormState = contextRef.current.fields void executeOnChange(submitted)
for (const onChangeFn of onChange) {
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
revalidatedFormState = await onChangeFn({
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
submitted,
})
}
if (!revalidatedFormState) {
return
}
const { changed, newState } = mergeServerFormState({
existingState: contextRef.current.fields || {},
incomingState: revalidatedFormState,
})
if (changed) {
dispatchFields({
type: 'REPLACE_STATE',
optimize: false,
state: newState,
})
}
} }
} }
if (modified) { isFirstRenderRef.current = false
void executeOnChange() prevFields.current = contextRef.current.fields
}
}, },
/* /*
Make sure we trigger this whenever modified changes (not just when `fields` changes), Make sure we trigger this whenever modified changes (not just when `fields` changes),
@@ -706,11 +715,8 @@ export const Form: React.FC<FormProps> = (props) => {
`fields` updates before `modified`, because setModified is in a setTimeout. `fields` updates before `modified`, because setModified is in a setTimeout.
So on the first change, modified is false, so we don't trigger the effect even though we should. So on the first change, modified is false, so we don't trigger the effect even though we should.
**/ **/
[contextRef.current.fields, modified, submitted], [modified, submitted, contextRef.current.fields],
[dispatchFields, onChange], 250,
{
delay: 250,
},
) )
return ( return (

View File

@@ -0,0 +1,36 @@
'use client'
/**
Taken and modified from https://github.com/bluesky-social/social-app/blob/ce0bf867ff3b50a495d8db242a7f55371bffeadc/src/lib/hooks/useNonReactiveCallback.ts
Copyright 20232025 Bluesky PBC
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.
*/
import { useCallback, useInsertionEffect, useRef } from 'react'
// This should be used sparingly. It erases reactivity, i.e. when the inputs
// change, the function itself will remain the same. This means that if you
// use this at a higher level of your tree, and then some state you read in it
// changes, there is no mechanism for anything below in the tree to "react"
// to this change (e.g. by knowing to call your function again).
//
// Also, you should avoid calling the returned function during rendering
// since the values captured by it are going to lag behind.
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function useEffectEvent<T extends Function>(fn: T): T {
const ref = useRef(fn)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback((...args: any) => {
const latestFn = ref.current
return latestFn(...args)
}, []) as unknown as T
}

View File

@@ -1,73 +0,0 @@
import { dequal } from 'dequal/lite'
import { useEffect, useRef } from 'react'
import { useDebouncedEffect } from './useDebouncedEffect.js'
/**
* Allows for a `useEffect` hook to be precisely triggered based on whether a only _subset_ of its dependencies have changed, as opposed to all of them. This is useful if you have a list of dependencies that change often, but need to scope your effect's logic to only explicit dependencies within that list.
* @constructor
* @param {React.EffectCallback} effect - The effect to run
* @param {React.DependencyList} deps - Dependencies that should trigger the effect
* @param {React.DependencyList} ignoredDeps - Dependencies that should _not_ trigger the effect
* @param {Object} options - Additional options to configure the hook
* @param {boolean} options.runOnFirstRender - Whether the effect should run on the first render
* @example
* useIgnoredEffect(() => {
* console.log('This will run when `foo` changes, but not when `bar` changes')
* }, [foo], [bar])
*/
export function useIgnoredEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
ignoredDeps: React.DependencyList,
options?: { runOnFirstRender?: boolean },
) {
const hasInitialized = useRef(
typeof options?.runOnFirstRender !== 'undefined' ? Boolean(!options?.runOnFirstRender) : false,
)
const prevDeps = useRef(deps)
useEffect(() => {
const depsHaveChanged = deps.some(
(dep, index) => !ignoredDeps.includes(dep) && !dequal(dep, prevDeps.current[index]),
)
if (depsHaveChanged || !hasInitialized.current) {
effect()
}
prevDeps.current = deps
hasInitialized.current = true
}, deps)
}
export function useIgnoredEffectDebounced(
effect: React.EffectCallback,
deps: React.DependencyList,
ignoredDeps: React.DependencyList,
options?: { delay?: number; runOnFirstRender?: boolean },
) {
const hasInitialized = useRef(
typeof options?.runOnFirstRender !== 'undefined' ? Boolean(!options?.runOnFirstRender) : false,
)
const prevDeps = useRef(deps)
useDebouncedEffect(
() => {
const depsHaveChanged = deps.some(
(dep, index) => !ignoredDeps.includes(dep) && !dequal(dep, prevDeps.current[index]),
)
if (depsHaveChanged || !hasInitialized.current) {
effect()
}
prevDeps.current = deps
hasInitialized.current = true
},
deps,
options?.delay || 0,
)
}

15
pnpm-lock.yaml generated
View File

@@ -516,8 +516,8 @@ importers:
specifier: 3.9.1 specifier: 3.9.1
version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3) version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3)
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: 5.0.0 specifier: 0.0.0-experimental-a4b2d0d5-20250203
version: 5.0.0(eslint@9.14.0(jiti@1.21.6)) version: 0.0.0-experimental-a4b2d0d5-20250203(eslint@9.14.0(jiti@1.21.6))
eslint-plugin-regexp: eslint-plugin-regexp:
specifier: 2.6.0 specifier: 2.6.0
version: 2.6.0(eslint@9.14.0(jiti@1.21.6)) version: 2.6.0(eslint@9.14.0(jiti@1.21.6))
@@ -570,8 +570,8 @@ importers:
specifier: 3.9.1 specifier: 3.9.1
version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3) version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3)
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: 5.0.0 specifier: 0.0.0-experimental-a4b2d0d5-20250203
version: 5.0.0(eslint@9.14.0(jiti@1.21.6)) version: 0.0.0-experimental-a4b2d0d5-20250203(eslint@9.14.0(jiti@1.21.6))
eslint-plugin-regexp: eslint-plugin-regexp:
specifier: 2.6.0 specifier: 2.6.0
version: 2.6.0(eslint@9.14.0(jiti@1.21.6)) version: 2.6.0(eslint@9.14.0(jiti@1.21.6))
@@ -6609,8 +6609,8 @@ packages:
typescript: typescript:
optional: true optional: true
eslint-plugin-react-hooks@5.0.0: eslint-plugin-react-hooks@0.0.0-experimental-a4b2d0d5-20250203:
resolution: {integrity: sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==} resolution: {integrity: sha512-g2X7ucBnIbWs2fJGa3XMCgB0HzCqmwEjsUam+HI8J3fxv9cdoktDqOrS/zpVG/YGTXrhWml2C+fbKYdGGoGrKg==}
engines: {node: '>=10'} engines: {node: '>=10'}
peerDependencies: peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@@ -7829,6 +7829,7 @@ packages:
lodash.get@4.4.2: lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -16044,7 +16045,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-react-hooks@5.0.0(eslint@9.14.0(jiti@1.21.6)): eslint-plugin-react-hooks@0.0.0-experimental-a4b2d0d5-20250203(eslint@9.14.0(jiti@1.21.6)):
dependencies: dependencies:
eslint: 9.14.0(jiti@1.21.6) eslint: 9.14.0(jiti@1.21.6)