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
Nobody has claimed this yet.
- 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 (AnimateHeight → display: 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.
elements/DocumentFields/index.tsx:74,90(forceRender)fields/Array/index.tsx:429(passesforceRenderto each row)elements/RenderIfInViewport/index.tsx:14,24(shouldRender = forceRender || …)fields/Array/ArrayRow.tsx:163-172(RenderFieldsrendered regardless ofrow.collapsed)elements/AnimateHeight/index.tsx:19(collapsed =display: none, still mounted)
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.
forms/RowLabel/index.tsx:16(always wraps inRowLabelProvider)forms/RowLabel/Context/index.tsx:24-26(useWatchForm()+getSiblingData+getDataByPath)forms/Form/index.tsx:898-901(FormWatchContext value={{ fields: formState, ...contextRef.current }})
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.
- Clone the repo, then
cp .env.example .envandpnpm install. pnpm payload run src/seed.ts. This createsdev@payloadcms.com/testand one page with 170items,
each with onechildrenrow and onerelatedvalue.pnpm build && pnpm start, log in, and open Pages → "Large array (170 rows)". Every row is collapsed.- 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. - 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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