payloadcms / payloadcms/payload

Array fields: typing lag scales with the number of mounted rows. Collapsed rows mount all their fields, and every row label walks the whole form state on each keystroke

Open
#18,182 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Bug
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

In the admin edit view, typing into any field on a document with a large array becomes very slow, and the
slowdown is linear in the number of array rows mounted. We traced it to two independent causes in
@payloadcms/ui. Each has a one-line fix, and together the two fixes remove almost all of the cost. Measured
numbers and the mechanism are below, and we're happy to open a PR.

Cause 1: collapsed array rows mount all their fields. DocumentFields passes forceRender to its
fields, and Tabs, Row and Array pass it on. RenderIfInViewport renders immediately when forceRender
is set, so the viewport laziness never applies. ArrayRow always passes <RenderFields> as the collapsible's
children, and a collapsed row only hides them (AnimateHeightdisplay: none). With every row collapsed
and nothing visible, a 171-row array still mounted ~51,000 DOM nodes, ~1,700 inputs and ~690 react-select
instances. Each mounted relationship field also fetches its labels on mount, which meant 171 requests on
page load.

Cause 2: every row label re-walks the entire form state on every keystroke. RowLabel always wraps its
label in RowLabelProvider, whether it's a custom RowLabel component or the default label. The provider
calls useWatchForm() and then getSiblingData(path) and getDataByPath(path) on every render. Each of
those walks the whole field-state map. FormWatchContext receives a new object on every Form render, and
the form re-renders on every keystroke. So each keystroke costs mounted rows × 2 × all fields in the form,
even when the keystroke is in an unrelated field.

Both causes are present on 3.x (3.89.0, be896e3eec) and on main (4.0.0-canary.14).

Measurements

Minimal reproduction (linked below): production build, Payload 3.89.0, Next.js 16.3.3, React 19.2.6,
default row labels, one document with 170 array rows (each with one nested child row and a hasMany
relationship), every row collapsed. 12 synthetic keystrokes 120 ms apart, measured to the next animation frame.

Unpatched With both fixes below
DOM nodes on load 41,017 10,076
Inputs + textareas / react-select instances 1,364 / 340 4 / 0
Array rows mounted 340 (170 items + 170 collapsed children) 170 (item headers only)
Relationship label requests on load 170 0
Main-thread long tasks during load 3.9 s 1.1 s
Typing in an unrelated text field 203 ms / keystroke 17 ms
Typing in an opened child row 268 ms / keystroke 43 ms

Our real data, for scale. Two real documents in one collection. Each row has ~15 fields, including a select, two hasMany
relationship fields and a nested array. All rows are collapsed (initCollapsed: true). The collection
uses custom RowLabel components that read useRowLabel(). Cause 2 doesn't depend on them, because
RowLabelProvider also wraps the default label, and the minimal reproduction above confirms it with default
labels.

  • Document A: one top-level array row containing a nested array of 171 rows. About 4,000 fields in
    form state.
  • Document B: 16 top-level rows with nested arrays totalling 94 rows (up to 20 in one row).

Method: 12 synthetic keystrokes 120 ms apart in a top-level text field (not useAsTitle, to avoid #17834).
Per-keystroke time is measured to the next animation frame. Every comparison was made in the same session.
⚠️ These were measured with next dev, which inflates absolute times several-fold. On a production build,
Document A unpatched is ~0.8 s per keystroke rather than ~3.2 s. The ratios are the finding.

Document State Unpatched + fix 1 (collapsed rows render nothing) + fix 1 + fix 2
A all rows collapsed 3,192 ms 91 ms 42 ms
A the one parent row open (171 collapsed child rows mounted) —¹ 776 ms 71 ms
A one child row open, typing inside it —¹ 965 ms 201 ms
B all rows collapsed 1,454 ms 158 ms 53 ms
B largest row open (20 child rows) —¹ 192 ms 28 ms
B one child row open, typing inside it —¹ 216 ms 78 ms

¹ Without fix 1 every row is always mounted, so these states cost the same as the first row for that document.

Page load for Document A went from 51,300 to 880 DOM nodes and from 171 relationship-label requests to 0.
Main-thread long tasks during load dropped from 29.4 s to 2.8 s.

Two controls isolated the cause. Both were on Document A's data, with fix 1 applied and 171 collapsed child rows mounted:

Control Per keystroke
Normal 720 ms
Form-state server action held (never sent) during typing 684 ms → the round trip and merge are not the cost
Same form state, parent row collapsed so the 171 row headers unmount 73 ms → the cost is the mounted row headers (~3.8 ms each)
Link to the code that reproduces this issue

https://github.com/Digital-Republic-Group/payload-array-rows-repro (start with REPRO.md)

Reproduction Steps

The linked repo is create-payload-app@3.89.0 -t blank (SQLite) plus one collection,
src/collections/Pages.ts,
and a seed script. Default row labels, no custom components.

  1. Clone the repo, then cp .env.example .env and pnpm install.
  2. pnpm payload run src/seed.ts. This creates dev@payloadcms.com / test and one page with 170 items,
    each with one children row and one related value.
  3. pnpm build && pnpm start, log in, and open Pages → "Large array (170 rows)". Every row is collapsed.
  4. Type into Probe. On a production build each keystroke blocks the main thread for ~200 ms (~17 ms with
    both fixes below). The DOM contains the fields of every collapsed row, and one relationship-label request
    fires per row on load.
  5. Without expanding anything, turn on "Highlight updates" in React DevTools: every array row's label
    re-renders on each keystroke in Probe.

The collection, for reference:

{
  slug: 'pages',
  fields: [
    { name: 'title', type: 'text' },
    { name: 'probe', type: 'text' }, // type here
    {
      name: 'items',
      type: 'array',
      admin: { initCollapsed: true },
      fields: [
        { name: 'label', type: 'text' },
        { name: 'kind', type: 'select', options: ['a', 'b', 'c'] },
        { name: 'notes', type: 'textarea' },
        { name: 'enabled', type: 'checkbox' },
        { name: 'related', type: 'relationship', relationTo: 'pages', hasMany: true },
        { name: 'children', type: 'array', admin: { initCollapsed: true }, fields: [{ name: 'text', type: 'textarea' }] },
      ],
    },
  ],
}
Proposed fix

Fix 1: don't render a row's fields while it is collapsed (fields/Array/ArrayRow.tsx):

{isLoading ? <ShimmerEffect /> : row.collapsed ? null : (
  <RenderFields /* …unchanged… */ />
)}

Field values live in server-built form state, so unmounted fields keep their values. RenderIfInViewport
already relies on that. Row error counts come from errorPaths in form state, so error pills still show on
collapsed rows. BlocksRow has the same pattern, but we didn't measure it. An alternative is to stop
forceRender from bypassing viewport laziness for collapsed content. Your call on which is cleaner.

Fix 2: don't subscribe RowLabelProvider to the per-keystroke watch context
(forms/RowLabel/Context/index.tsx). What we measured was simply:

const { getDataByPath, getSiblingData } = useForm() // was: useWatchForm()

FormContext is stable across keystrokes, and the getters read contextRef.current.fields at call time, so
the data is current whenever the label renders. Trade-off we observed: a row's label no longer updates
live while you type into that same row's label source field. It catches up the next time the row re-renders.
This applies only to custom RowLabel components that read useRowLabel(). With the default label there is
no visible difference, because it reads no row data. The reproduction confirms this.
A fuller fix would subscribe each label only to its own row's fields, so it re-renders when those change and
never re-walks the whole form.

Correctness checks, with both fixes applied: row labels rendered the correct text on both documents
(verified against the API response), and the trade-off above was the only behaviour difference we found.

Which area(s) are affected?

area: ui. It reproduces on both db: sqlite (the linked repo) and db: mongodb (our real data), so it isn't
database-specific.

Environment Info
Binaries:
  Node: 22.20.0
  npm: 10.9.3
  pnpm: 9.15.9
Relevant Packages:
  payload: 3.89.0
  next: 16.3.3
  @payloadcms/db-sqlite: 3.89.0
  @payloadcms/drizzle: 3.89.0
  @payloadcms/graphql: 3.89.0
  @payloadcms/next/utilities: 3.89.0
  @payloadcms/richtext-lexical: 3.89.0
  @payloadcms/translations: 3.89.0
  @payloadcms/ui/shared: 3.89.0
  react: 19.2.6
  react-dom: 19.2.6
Operating System:
  Platform: linux
  Arch: x64
  Available CPU cores: 8

Minimal reproduction: payload / @payloadcms/ui / @payloadcms/db-sqlite 3.89.0, Next.js 16.3.3, React 19.2.6,
production build. Real-data measurements: 3.83.0 with @payloadcms/db-mongodb, Next.js 15.4.11, next dev.
The same code is on 3.89.0 (3.x @ be896e3eec) and main (4.0.0-canary.14).

Related
  • #6134: large array fields make typing "erratic and unresponsive" (2024, closed without a repro).
  • #11650: performance issue with a ~1,000-element array (closed; a feature request was invited).
  • #13753: the form-state server action posts the full form state. Separate from this issue: in our
    measurements that round trip was not what blocked typing.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with REPRO.md in the linked reproduction, then inspect fields/Array/ArrayRow.tsx, elements/RenderIfInViewport/index.tsx, and forms/RowLabel/Context/index.tsx. Verify that collapsed rows do not mount their fields and that row labels remain correct without walking the whole form state on each keystroke. Re-run the reproduction and confirm the reported DOM, request, and typing improvements.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, react, typescript
Domain
frontend, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.