sanity-form-component-suspense
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCustom form components that suspend
会触发挂起的自定义表单组件
Studio puts a fallback boundary around every field and every array item, so one suspending
component cannot blank the form. That fallback is a generic skeleton: it does not know the size of
what it replaces, so the form shifts when the component loads, and Studio warns about it in the
console. The fix always lives in your code: declare a around the part that suspends,
with a fallback that takes the same space as what it stands in for. Any boundary you declare is
closer to what suspends than Studio's, so it always wins and nothing warns. That includes a
boundary in a custom field around , which also covers the field's
input: use it when one fallback should cover both, for example a lazy input that also fetches.
Suspenseprops.renderDefault(props)Studio 会在每个 field 和 array item 周围放置一个 fallback boundary,因此单个 suspending component 不会让整个 form 变成空白。这个 fallback 是通用 skeleton:它不知道所替代内容的大小,因此组件加载时 form 会发生位移,同时 Studio 也会在 console 中发出警告。修复始终应放在你自己的代码中:在会触发挂起的部分周围声明 ,并提供一个与其替代内容占用相同空间的 fallback。你声明的任何 boundary 都比 Studio 的 boundary 更靠近触发挂起的组件,因此它始终会胜出,也不会再出现警告。这也包括在 custom field 中围绕 声明 boundary;该 boundary 也会覆盖 field 的 input:如果一个 fallback 需要同时覆盖两者(例如,既是 lazy input 又会发起 fetch),就应采用这种方式。
Suspenseprops.renderDefault(props)Workflow
工作流程
- Find the component. The warning names the custom components declared for that field or
item (,
components.fieldor.input), the path, the document type and the schema type, and logs the components themselves; click one in the browser console to open its source. Production builds minify the names, so use the path and document type there. When the schema type declares none, the warning starts with "A form component" and the culprit is usually a plugin's.item. React DevTools' Suspense tab shows which component actually suspended.form.components - Find what suspends. Usually one of: ,
React.lazy, an App SDK hook (use(promise),useDocumentProjection,useDocument,useQuery), or a library in suspense mode (useDocuments).useSuspenseQuery - Move it into its own component if it is not already, and wrap only that component in
inside the custom component. Keep
<Suspense>outside the boundary, so the built-in input, field or item never unmounts (and never loses focus while someone types) when the async part suspends again.props.renderDefault(props) - Reserve the space. The fallback must have the size of the loaded UI:
- Additive UI (warnings, previews, counts): render it after so it never pushes the rest down, and give the fallback the height of one loaded row.
renderDefault - A whole component that loads lazily: a skeleton with the component's height, not .
null - Array items: the list is virtualized and measures each row, so an item fallback that is shorter or taller than the loaded row makes the rows below it jump. Match the row height.
- Additive UI (warnings, previews, counts): render it after
- Do not suspend on a document that does not exist yet. A new document only exists once the
first edit creates it, and SDK hooks that read the current document wait for it forever. Gate
on : it is unset until the document exists.
useFormValue(['_createdAt']) - Keep promises stable. A promise created during render, in or in
useStateis thrown away while the component suspends on mount, souseMemogets a new one every retry and never settles. Use the SDK hooks, a module-scope cache keyed by the inputs, or a data library.use() - Verify. Open a new document of the type and an existing one: the warning is gone, the rest of the form is editable right away, and nothing moves when the async part lands.
- 找到组件。 警告会指出为该 field 或 item 声明的 custom components(、
components.field或.input)、path、document type 和 schema type,还会输出这些组件本身;在 browser console 中点击其中一个即可打开其源代码。Production builds 会压缩这些名称,因此在那里应结合 path 和 document type 定位。如果 schema type 没有声明任何组件,警告会以 "A form component" 开头,源头通常是某个 plugin 的.item。React DevTools 的 Suspense tab 可以显示究竟是哪个组件触发了挂起。form.components - 找到触发挂起的部分。 通常是以下之一:、
React.lazy、App SDK hook(use(promise)、useDocumentProjection、useDocument、useQuery),或处于 suspense mode 的 library(useDocuments)。useSuspenseQuery - 将它移入独立组件。 如果尚未独立,就将它移入,然后在 custom component 内仅用 包裹这个组件。将
<Suspense>放在 boundary 外,这样 async part 再次触发挂起时,内置 input、field 或 item 不会卸载(用户输入时也不会丢失焦点)。props.renderDefault(props) - 预留空间。 fallback 的大小必须与加载完成后的 UI 相同:
- 附加型 UI(warnings、previews、counts):在 之后渲染,这样它不会把其余内容向下推;并让 fallback 的高度等于一行加载完成后的内容。
renderDefault - 整个 lazy-loaded component:使用高度与该 component 相同的 skeleton,不要使用 。
null - Array items:list 会进行 virtualize,并测量每一行;item fallback 比加载后的 row 矮或高,都会让下方各行跳动。高度应与 row 相同。
- 附加型 UI(warnings、previews、counts):在
- 不要在文档尚不存在时触发挂起。 新文档只有在首次编辑创建后才真正存在,而读取当前文档的 SDK hooks 会一直等待它。以 作为 gate:在文档存在之前,该值都处于未设置状态。
useFormValue(['_createdAt']) - 保持 Promise 稳定。 在 render 期间或通过 或
useState创建的 Promise,会在 component 挂载并触发挂起期间被丢弃,因此useMemo每次重试都会得到一个新 Promise,永远无法 settle。请使用 SDK hooks、以 inputs 为 key 的 module-scope cache,或 data library。use() - 验证。 打开该类型的新文档和一个已有文档:警告已经消失,form 的其余部分可立即编辑,并且 async part 加载完成时页面不会发生移动。
Example
示例
tsx
import {createDocumentHandle, useDocumentProjection} from '@sanity/sdk-react'
import {Card, Skeleton, Stack, Text} from '@sanity/ui'
import {Suspense} from 'react'
import {type ArrayOfObjectsInputProps, getPublishedId, useFormValue} from 'sanity'
export function ModelSizeInput(props: ArrayOfObjectsInputProps) {
const documentId = useFormValue(['_id']) as string
const documentExists = Boolean(useFormValue(['_createdAt']))
const hasModels = (props.value?.length ?? 0) > 0
return (
<Stack gap={3}>
{props.renderDefault(props)}
{documentExists && hasModels ? (
<Suspense fallback={<Skeleton radius={2} style={{height: 33}} animated />}>
<ModelSizeWarnings documentId={getPublishedId(documentId)} />
</Suspense>
) : null}
</Stack>
)
}
function ModelSizeWarnings(props: {documentId: string}) {
const {data} = useDocumentProjection<{missing: number}>({
...createDocumentHandle({documentType: 'product', documentId: props.documentId}),
projection: `{"missing": count(models[!defined(@->size)])}`,
})
return (
<Card padding={2} radius={2} tone={data.missing > 0 ? 'critical' : 'positive'}>
<Text size={1}>{data.missing} models are missing a size</Text>
</Card>
)
}The skeleton's height matches the one-line card it stands in for. If the loaded UI's height
varies, reserve the most common case and keep the variable part below the input.
tsx
import {createDocumentHandle, useDocumentProjection} from '@sanity/sdk-react'
import {Card, Skeleton, Stack, Text} from '@sanity/ui'
import {Suspense} from 'react'
import {type ArrayOfObjectsInputProps, getPublishedId, useFormValue} from 'sanity'
export function ModelSizeInput(props: ArrayOfObjectsInputProps) {
const documentId = useFormValue(['_id']) as string
const documentExists = Boolean(useFormValue(['_createdAt']))
const hasModels = (props.value?.length ?? 0) > 0
return (
<Stack gap={3}>
{props.renderDefault(props)}
{documentExists && hasModels ? (
<Suspense fallback={<Skeleton radius={2} style={{height: 33}} animated />}>
<ModelSizeWarnings documentId={getPublishedId(documentId)} />
</Suspense>
) : null}
</Stack>
)
}
function ModelSizeWarnings(props: {documentId: string}) {
const {data} = useDocumentProjection<{missing: number}>({
...createDocumentHandle({documentType: 'product', documentId: props.documentId}),
projection: `{"missing": count(models[!defined(@->size)])}`,
})
return (
<Card padding={2} radius={2} tone={data.missing > 0 ? 'critical' : 'positive'}>
<Text size={1}>{data.missing} models are missing a size</Text>
</Card>
)
}Skeleton 的高度与它所替代的单行 card 相同。如果加载完成后的 UI 高度可变,请预留最常见情形的高度,并将可变部分放在 input 下方。