Skip to content

perf(ui): skip rendering custom components hidden by admin.condition#16780

Merged
jacobsfletch merged 18 commits into
mainfrom
perf/server-component-rendering-conditions
Jun 2, 2026
Merged

perf(ui): skip rendering custom components hidden by admin.condition#16780
jacobsfletch merged 18 commits into
mainfrom
perf/server-component-rendering-conditions

Conversation

@jacobsfletch

@jacobsfletch jacobsfletch commented May 28, 2026

Copy link
Copy Markdown
Member

Form state was eagerly rendering custom components even when the field's admin.condition evaluates to false. The rendered React component was only being hidden client-side, but should have deferred rendering entirely.

This has potentially serious performance implications for two reasons:

  1. Rendering overhead. Despite not mounting to the DOM, custom components still render prematurely, executing potentially expensive tasks like querying the database, etc.
  2. Hidden fields still receive full recursion. Nested fields schemas are still traversed, build state, and resolve their filter options, etc. This includes the rendering step mentioned above.

Now, custom server components DO NOT render unless they pass conditions, as expected. Custom components will now only render if they will be mounted to the page.

Before:

Screen.Recording.2026-05-29.at.10.21.27.AM.mp4

After:

Screen.Recording.2026-05-29.at.10.19.26.AM.mp4

… admin.condition

Initial form-state build was eagerly rendering every field's custom
components (Field, BeforeInput, AfterInput, Description, Error, Label,
RowLabel), including server components, even when the field's
admin.condition resolved false. The rendered React element was baked
into fieldState.customComponents and only hidden client-side by
WatchCondition — work was already done.

Gate the renderFieldFn call in addFieldStatePromise on
`passesCondition !== false`. When the condition later flips true via
onChange, lastRenderedPath is undefined so renderField produces a fresh
element with a current timestamp.

Note: inline function conditions only. Path-valued string refs
(`./Path#export`) are not resolved server-side in iterateFields yet —
those still pre-render.
@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 1.03 MB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.41 MB ✅ No change
packages/payload/meta_shared.json esbuild/exports/shared.js 192.51 KB ✅ No change
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 304.12 KB ⚠️ +1 B (+0.0%)
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.27 MB ⚠️ +1.05 KB (+0.1%)
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.56 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████████ }}}$ 84.0%, 861.10 KB
dist/views/Version ${{\color{Goldenrod}{ █▎ }}}$ 5.3%, 54.61 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 21.85 KB
dist/views/Document ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 16.76 KB
dist/views/List ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 15.51 KB
dist/views/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 10.01 KB
dist/views/Account ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 6.13 KB
dist/views/API ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 6.13 KB
dist/views/Versions ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 5.74 KB
dist/views/Login ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 4.62 KB
dist/layouts/Root ${{\color{Goldenrod}{ }}}$ 0.4%, 3.61 KB
dist/views/ForgotPassword ${{\color{Goldenrod}{ }}}$ 0.3%, 3.22 KB
dist/views/CreateFirstUser ${{\color{Goldenrod}{ }}}$ 0.3%, 3.02 KB
dist/views/ResetPassword ${{\color{Goldenrod}{ }}}$ 0.2%, 2.46 KB
dist/views/Logout ${{\color{Goldenrod}{ }}}$ 0.2%, 1.96 KB
dist/views/Verify ${{\color{Goldenrod}{ }}}$ 0.1%, 1.35 KB
dist/utilities/initReq.js ${{\color{Goldenrod}{ }}}$ 0.1%, 1.15 KB
dist/views/NotFound ${{\color{Goldenrod}{ }}}$ 0.1%, 1.04 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.1%, 659 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.1%, 531 B
(other) ${{\color{Goldenrod}{ ████ }}}$ 16.0%, 163.94 KB

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████ }}}$ 68.4%, 959.51 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 44.07 KB
dist/collections/operations ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 40.23 KB
dist/versions/migrations ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 18.50 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.63 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.40 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.40 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 13.10 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.63 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.57 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.53 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.44 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.92 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.80 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.77 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.65 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.54 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.23 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.43 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.31 KB
(other) ${{\color{Goldenrod}{ ███████▉ }}}$ 31.6%, 443.27 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▉ }}}$ 79.5%, 150.12 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 10.57 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.54 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.29 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 1.04 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 943 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.3%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 525 B
dist/fields/getFieldPaths.js ${{\color{Goldenrod}{ }}}$ 0.3%, 485 B
dist/utilities/appendDateTimezoneSelectFields.js ${{\color{Goldenrod}{ }}}$ 0.2%, 451 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.2%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.2%, 413 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.5%, 38.61 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███ }}}$ 12.4%, 37.36 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▊ }}}$ 11.4%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▋ }}}$ 10.9%, 32.88 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▎ }}}$ 9.0%, 27.16 KB
dist/packages/@lexical ${{\color{Goldenrod}{ █▌ }}}$ 6.3%, 18.99 KB
dist/features/link ${{\color{Goldenrod}{ █▌ }}}$ 6.3%, 18.81 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.5%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.7%, 11.08 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 9.39 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.79 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.36 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.40 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 7.29 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.00 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.46 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.84 KB
(other) ${{\color{Goldenrod}{ █████████████████████▉ }}}$ 87.6%, 263.53 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████▍ }}}$ 45.9%, 579.79 KB
dist/elements/Hierarchy ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 44.00 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 28.06 KB
dist/views/HierarchyList ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 20.40 KB
dist/elements/Table ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 19.25 KB
dist/views/Edit ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 17.57 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 17.34 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.87 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 15.47 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 15.11 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 14.39 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.22 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.01 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.38 KB
dist/elements/LivePreview ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.25 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.84 KB
dist/fields/Array ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.76 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.75 KB
dist/elements/Nav ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.54 KB
dist/elements/RelationshipTable ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 6.69 KB
(other) ${{\color{Goldenrod}{ █████████████▌ }}}$ 54.1%, 682.41 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▊ }}}$ 31.1%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▋ }}}$ 14.8%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██▏ }}}$ 8.5%, 1.52 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.4%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 734 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 399 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 159 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 147 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 146 B
(other) ${{\color{Goldenrod}{ █████████████████▏ }}}$ 68.9%, 12.36 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

@jacobsfletch jacobsfletch changed the title perf(ui): skip rendering custom field components for fields hidden by admin.condition perf(ui): skip rendering custom field components that fail admin.condition May 28, 2026
@jacobsfletch jacobsfletch changed the title perf(ui): skip rendering custom field components that fail admin.condition perf(ui): skip rendering custom field components hidden by admin.condition May 28, 2026
…s collection

Keeps base Posts collection from accruing condition-specific fixtures.
Cleaner than matching against a magic title string.
…n.condition

Previously only the render call was gated on passesCondition; the switch
still ran filterOptions resolves (DB queries on relationship/upload),
blocks validation, and full recursion into hidden subtrees. Add an
early-exit guard so a failing condition writes a minimal state entry and
returns, skipping access checks, validation, switch processing,
filterOptions, and child iteration. The client re-requests form state on
condition flips, so the minimal entry is sufficient.
Now that the tabs branch unconditionally writes state[path] with
passesCondition, the early-exit guard no longer needs to exclude tabs
and tab field types. Dropping the exclusion skips child iteration for
hidden tabs containers and tabs whose admin.condition fails. The tab
branch is also simplified — incoming passesCondition is guaranteed true
past the guard, so tab visibility depends solely on its own
admin.condition.
@jacobsfletch jacobsfletch marked this pull request as ready for review June 1, 2026 16:40
The short-circuit at the top of addFieldStatePromise skips the tab
branch when passesCondition is false, but the tab branch is where
state[field.id] gets written, and the Tabs component on the client
reads tab visibility from that key. Without the write, hidden tabs
fall back to passesCondition ?? true and render visible.

Mirror the state[field.id] write in the short-circuit so tabs that
fail their admin.condition pick up the --hidden class as expected.
The previous fix mirrored the state[field.id] write inside the
short-circuit, which required developers to remember that tab fields
have a dual-keyed state contract (state[path] AND state[field.id]).

Move that responsibility back to where it belongs — the tab branch —
and exclude tab from the short-circuit. The tab branch now:

- Uses the incoming passesCondition resolved by iterateFields instead
  of re-evaluating field.admin.condition.
- Writes state[field.id] unconditionally.
- Returns early when the tab is hidden, skipping recursion into
  descendants (matches the short-circuit's intent without duplicating
  its body).

Net effect is the same perf win with one tab-specific concern living in
one place.
Reorganize the tab branch so the flow reads linearly:

1. Strip-unselected check (return if not selected).
2. Write state[field.id] (the visibility marker the Tabs component reads).
3. Return early if the tab is hidden — no recursion needed.
4. Resolve child permissions and select scope.
5. Recurse into children.

Previously the permissions/select setup ran before the hidden-tab early
return, which was wasted work and obscured the control flow.
@jacobsfletch jacobsfletch requested a review from denolfe as a code owner June 1, 2026 18:49
When a tab fails its admin.condition, the tab branch writes
state[field.id] but skips recursion into descendants. If the tab later
flips visible (or the tab itself was first surfaced after a previously
hidden ancestor became visible), the server rebuilds state and writes a
fresh state[field.id]. Without the addedByServer flag,
mergeServerFormState skips brand-new entries that aren't already on the
client, leaving the Tabs component reading a stale (or missing) value
and rendering the tab with the wrong visibility.

Mirror the addedByServer logic used for non-tab fields so newly minted
tab entries actually reach the client.
@jacobsfletch jacobsfletch changed the title perf(ui): skip rendering custom field components hidden by admin.condition perf(ui): skip rendering custom components hidden by admin.condition Jun 1, 2026
@jacobsfletch jacobsfletch merged commit bd22444 into main Jun 2, 2026
325 of 328 checks passed
@jacobsfletch jacobsfletch deleted the perf/server-component-rendering-conditions branch June 2, 2026 13:32
jacobsfletch added a commit that referenced this pull request Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants