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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
36
packages/ui/src/hooks/useEffectEvent.ts
Normal file
36
packages/ui/src/hooks/useEffectEvent.ts
Normal 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 2023–2025 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
|
||||
}
|
||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user