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-jsx-a11y": "6.10.2",
"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",
"globals": "15.12.0",
"typescript": "5.7.3",

View File

@@ -36,7 +36,7 @@
"eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"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",
"globals": "15.12.0",
"typescript": "5.7.3",

View File

@@ -3,7 +3,7 @@
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import { versionDefaults } from 'payload/shared'
import React, { useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
@@ -13,15 +13,15 @@ import {
useFormSubmitted,
} from '../../forms/Form/context.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 { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
import { formatTimeToNow } from '../../utilities/formatDate.js'
import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js'
import './index.scss'
const baseClass = 'autosave'
// 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
useIgnoredEffect(
() => {
const abortController = new AbortController()
let autosaveTimeout = undefined
// We need to log the time in order to figure out if we need to trigger the state off later
let startTimestamp = undefined
let endTimestamp = undefined
const handleAutosave = useEffectEvent(() => {
const abortController = new AbortController()
let autosaveTimeout = undefined
// We need to log the time in order to figure out if we need to trigger the state off later
let startTimestamp = undefined
let endTimestamp = undefined
const autosave = async () => {
if (modified) {
startTimestamp = new Date().getTime()
const autosave = async () => {
if (modified) {
startTimestamp = new Date().getTime()
setSaving(true)
setSaving(true)
let url: string
let method: string
let entitySlug: string
let url: string
let method: string
let entitySlug: string
if (collection && id) {
entitySlug = collection.slug
url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH'
}
if (collection && id) {
entitySlug = collection.slug
url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH'
}
if (globalDoc) {
entitySlug = globalDoc.slug
url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST'
}
if (globalDoc) {
entitySlug = globalDoc.slug
url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST'
}
if (url) {
if (modifiedRef.current) {
const { data, valid } = {
...reduceFieldsToValuesWithValidation(fieldRef.current, true),
}
data._status = 'draft'
const skipSubmission =
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (url) {
if (modifiedRef.current) {
const { data, valid } = {
...reduceFieldsToValuesWithValidation(fieldRef.current, true),
}
data._status = 'draft'
const skipSubmission =
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission) {
await fetch(url, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
method,
signal: abortController.signal,
if (!skipSubmission) {
await fetch(url, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
method,
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) => {
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()
.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 (res.status === 200) {
setLastUpdateTime(newDate.getTime())
if (err?.message) {
newNonFieldErrs.push(err)
}
reportUpdate({
id,
entitySlug,
updatedAt: newDate.toISOString(),
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,
})
if (!mostRecentVersionIsAutosaved) {
incrementVersionCount()
setMostRecentVersionIsAutosaved(true)
setUnpublishedVersionCount((prev) => prev + 1)
}
}
nonFieldErrors.forEach((err) => {
toast.error(err.message || i18n.t('error:unknown'))
})
return res.json()
})
.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 {
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)
}
})
}
}
}
}
}
queueRef.current.push(autosave)
void processQueue()
queueRef.current.push(autosave)
void processQueue()
return () => {
if (autosaveTimeout) {
clearTimeout(autosaveTimeout)
}
if (abortController.signal) {
try {
abortController.abort('Autosave closed early.')
} catch (error) {
// swallow error
}
}
setSaving(false)
return { abortController, autosaveTimeout }
})
// When debounced fields change, autosave
useEffect(() => {
const { abortController, autosaveTimeout } = handleAutosave()
return () => {
if (autosaveTimeout) {
clearTimeout(autosaveTimeout)
}
},
[debouncedFields],
[
api,
collection,
dispatchFields,
globalDoc,
i18n,
id,
interval,
modified,
reportUpdate,
serverURL,
setSubmitted,
versionsConfig?.drafts,
submitted,
setLastUpdateTime,
mostRecentVersionIsAutosaved,
incrementVersionCount,
setMostRecentVersionIsAutosaved,
],
)
if (abortController.signal) {
try {
abortController.abort('Autosave closed early.')
} catch (error) {
// swallow error
}
}
setSaving(false)
}
}, [debouncedFields])
return (
<div className={baseClass}>

View File

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

View File

@@ -1,21 +1,15 @@
'use client'
import type {
ClientCollectionConfig,
JoinFieldClient,
ListQuery,
PaginatedDocs,
Where,
} from 'payload'
import type { JoinFieldClient, ListQuery, PaginatedDocs, Where } from 'payload'
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 { Column } from '../Table/index.js'
import { Button } from '../../elements/Button/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 { useAuth } from '../../providers/Auth/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 { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js'
import './index.scss'
import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
import { TableColumnsProvider } from '../TableColumns/index.js'
import './index.scss'
import { DrawerLink } from './cells/DrawerLink/index.js'
import { RelationshipTablePagination } from './Pagination.js'
@@ -152,15 +146,15 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
],
)
useIgnoredEffect(
() => {
if (!disableTable && (!Table || query)) {
void renderTable()
}
},
[query, disableTable],
[Table, renderTable],
)
const handleTableRender = useEffectEvent((query: ListQuery, disableTable: boolean) => {
if (!disableTable && (!Table || query)) {
void renderTable()
}
})
useEffect(() => {
handleTableRender(query, disableTable)
}, [query, disableTable])
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, openDrawer }] = useDocumentDrawer({
collectionSlug: relationTo,

View File

@@ -1,6 +1,7 @@
'use client'
import type { PaginatedDocs, RelationshipFieldClientComponent, Where } from 'payload'
import { dequal } from 'dequal/lite'
import { wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm'
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 { withCondition } from '../../forms/withCondition/index.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 { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js'
import { createRelationMap } from './createRelationMap.js'
@@ -31,7 +33,6 @@ import { findOptionsByValue } from './findOptionsByValue.js'
import { optionsReducer } from './optionsReducer.js'
import { MultiValueLabel } from './select-components/MultiValueLabel/index.js'
import { SingleValue } from './select-components/SingleValue/index.js'
import './index.scss'
const maxResultsPerRequest = 10
@@ -306,89 +307,83 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
[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
// ///////////////////////////////////
useIgnoredEffect(
() => {
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())
},
[value],
[
options,
hasMany,
errorLoading,
getEntityConfig,
hasMultipleRelations,
serverURL,
api,
i18n,
relationTo,
locale,
config,
],
)
useEffect(() => {
if (isFirstRenderRef.current || !dequal(value, prevValue.current)) {
handleValueChange(value)
}
isFirstRenderRef.current = false
prevValue.current = value
}, [value])
// Determine if we should switch to word boundary search
useEffect(() => {
@@ -401,39 +396,39 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
setEnableWordBoundarySearch(!isIdOnly)
}, [relationTo, getEntityConfig])
const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => {
return await getResults(args)
})
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run
useIgnoredEffect(
() => {
// If the menu is open while filterOptions changes
// due to latency of form state and fast clicking into this field,
// re-fetch options
if (hasLoadedFirstPageRef.current && menuIsOpen) {
setIsLoading(true)
void getResults({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
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,
useEffect(() => {
// If the menu is open while filterOptions changes
// due to latency of form state and fast clicking into this field,
// re-fetch options
if (hasLoadedFirstPageRef.current && menuIsOpen) {
setIsLoading(true)
void getResultsEffectEvent({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
value: valueRef.current,
})
}
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
},
[relationTo, filterOptions, locale, path, menuIsOpen],
[getResults],
)
// 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)
setLastLoadedPage({})
}, [relationTo, filterOptions, locale, path, menuIsOpen])
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {

View File

@@ -21,7 +21,8 @@ import type {
SubmitOptions,
} 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 { useAuth } from '../../providers/Auth/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(' ')
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 (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,
})
}
if (isFirstRenderRef.current || !dequal(contextRef.current.fields, prevFields.current)) {
if (modified) {
void executeOnChange(submitted)
}
}
if (modified) {
void executeOnChange()
}
isFirstRenderRef.current = false
prevFields.current = contextRef.current.fields
},
/*
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.
So on the first change, modified is false, so we don't trigger the effect even though we should.
**/
[contextRef.current.fields, modified, submitted],
[dispatchFields, onChange],
{
delay: 250,
},
[modified, submitted, contextRef.current.fields],
250,
)
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
version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3)
eslint-plugin-react-hooks:
specifier: 5.0.0
version: 5.0.0(eslint@9.14.0(jiti@1.21.6))
specifier: 0.0.0-experimental-a4b2d0d5-20250203
version: 0.0.0-experimental-a4b2d0d5-20250203(eslint@9.14.0(jiti@1.21.6))
eslint-plugin-regexp:
specifier: 2.6.0
version: 2.6.0(eslint@9.14.0(jiti@1.21.6))
@@ -570,8 +570,8 @@ importers:
specifier: 3.9.1
version: 3.9.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.7.3)
eslint-plugin-react-hooks:
specifier: 5.0.0
version: 5.0.0(eslint@9.14.0(jiti@1.21.6))
specifier: 0.0.0-experimental-a4b2d0d5-20250203
version: 0.0.0-experimental-a4b2d0d5-20250203(eslint@9.14.0(jiti@1.21.6))
eslint-plugin-regexp:
specifier: 2.6.0
version: 2.6.0(eslint@9.14.0(jiti@1.21.6))
@@ -6609,8 +6609,8 @@ packages:
typescript:
optional: true
eslint-plugin-react-hooks@5.0.0:
resolution: {integrity: sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==}
eslint-plugin-react-hooks@0.0.0-experimental-a4b2d0d5-20250203:
resolution: {integrity: sha512-g2X7ucBnIbWs2fJGa3XMCgB0HzCqmwEjsUam+HI8J3fxv9cdoktDqOrS/zpVG/YGTXrhWml2C+fbKYdGGoGrKg==}
engines: {node: '>=10'}
peerDependencies:
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:
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:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -16044,7 +16045,7 @@ snapshots:
transitivePeerDependencies:
- 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:
eslint: 9.14.0(jiti@1.21.6)