sanity-form-component-suspense

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Custom 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
Suspense
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
props.renderDefault(props)
, which also covers the field's input: use it when one fallback should cover both, for example a lazy input that also fetches.
Studio 会在每个 field 和 array item 周围放置一个 fallback boundary,因此单个 suspending component 不会让整个 form 变成空白。这个 fallback 是通用 skeleton:它不知道所替代内容的大小,因此组件加载时 form 会发生位移,同时 Studio 也会在 console 中发出警告。修复始终应放在你自己的代码中:在会触发挂起的部分周围声明
Suspense
,并提供一个与其替代内容占用相同空间的 fallback。你声明的任何 boundary 都比 Studio 的 boundary 更靠近触发挂起的组件,因此它始终会胜出,也不会再出现警告。这也包括在 custom field 中围绕
props.renderDefault(props)
声明 boundary;该 boundary 也会覆盖 field 的 input:如果一个 fallback 需要同时覆盖两者(例如,既是 lazy input 又会发起 fetch),就应采用这种方式。

Workflow

工作流程

  1. Find the component. The warning names the custom components declared for that field or item (
    components.field
    ,
    .input
    or
    .item
    ), 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
    form.components
    . React DevTools' Suspense tab shows which component actually suspended.
  2. Find what suspends. Usually one of:
    React.lazy
    ,
    use(promise)
    , an App SDK hook (
    useDocumentProjection
    ,
    useDocument
    ,
    useQuery
    ,
    useDocuments
    ), or a library in suspense mode (
    useSuspenseQuery
    ).
  3. Move it into its own component if it is not already, and wrap only that component in
    <Suspense>
    inside the custom component. Keep
    props.renderDefault(props)
    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.
  4. Reserve the space. The fallback must have the size of the loaded UI:
    • Additive UI (warnings, previews, counts): render it after
      renderDefault
      so it never pushes the rest down, and give the fallback the height of one loaded row.
    • 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.
  5. 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
    useFormValue(['_createdAt'])
    : it is unset until the document exists.
  6. Keep promises stable. A promise created during render, in
    useState
    or in
    useMemo
    is thrown away while the component suspends on mount, so
    use()
    gets a new one every retry and never settles. Use the SDK hooks, a module-scope cache keyed by the inputs, or a data library.
  7. 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.
  1. 找到组件。 警告会指出为该 field 或 item 声明的 custom components(
    components.field
    、
    .input
    或
    .item
    )、path、document type 和 schema type,还会输出这些组件本身;在 browser console 中点击其中一个即可打开其源代码。Production builds 会压缩这些名称,因此在那里应结合 path 和 document type 定位。如果 schema type 没有声明任何组件,警告会以 "A form component" 开头,源头通常是某个 plugin 的
    form.components
    。React DevTools 的 Suspense tab 可以显示究竟是哪个组件触发了挂起。
  2. 找到触发挂起的部分。 通常是以下之一:
    React.lazy
    、
    use(promise)
    、App SDK hook(
    useDocumentProjection
    、
    useDocument
    、
    useQuery
    、
    useDocuments
    ),或处于 suspense mode 的 library(
    useSuspenseQuery
    )。
  3. 将它移入独立组件。 如果尚未独立,就将它移入,然后在 custom component 内仅用
    <Suspense>
    包裹这个组件。将
    props.renderDefault(props)
    放在 boundary 外,这样 async part 再次触发挂起时,内置 input、field 或 item 不会卸载(用户输入时也不会丢失焦点)。
  4. 预留空间。 fallback 的大小必须与加载完成后的 UI 相同:
    • 附加型 UI(warnings、previews、counts):在
      renderDefault
      之后渲染,这样它不会把其余内容向下推;并让 fallback 的高度等于一行加载完成后的内容。
    • 整个 lazy-loaded component:使用高度与该 component 相同的 skeleton,不要使用
      null
      。
    • Array items:list 会进行 virtualize,并测量每一行;item fallback 比加载后的 row 矮或高,都会让下方各行跳动。高度应与 row 相同。
  5. 不要在文档尚不存在时触发挂起。 新文档只有在首次编辑创建后才真正存在,而读取当前文档的 SDK hooks 会一直等待它。以
    useFormValue(['_createdAt'])
    作为 gate:在文档存在之前,该值都处于未设置状态。
  6. 保持 Promise 稳定。 在 render 期间或通过
    useState
    或
    useMemo
    创建的 Promise,会在 component 挂载并触发挂起期间被丢弃,因此
    use()
    每次重试都会得到一个新 Promise,永远无法 settle。请使用 SDK hooks、以 inputs 为 key 的 module-scope cache,或 data library。
  7. 验证。 打开该类型的新文档和一个已有文档:警告已经消失,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 下方。