Skip to content

perf: remove form state network requests#16446

Draft
jacobsfletch wants to merge 83 commits intomainfrom
perf/form-state-v4
Draft

perf: remove form state network requests#16446
jacobsfletch wants to merge 83 commits intomainfrom
perf/form-state-v4

Conversation

@jacobsfletch
Copy link
Copy Markdown
Member

@jacobsfletch jacobsfletch commented May 1, 2026

Exploration for #13753.

TL;DR:

It's possible to mostly, and in some cases, completely, remove the network layer from form state. However, this work isn't going to land in v4, mostly due to DX concerns, but could potentially land in a future major release. This PR will act research into the technical requirements of making this change.

Background

Since Payload v3, every single document event fires a unique HTTP request for "form state", which includes RSCs in its text/x-component response type—a primary responsibility of this endpoint. This is because server components, by definition, require a server to render.

This means that if you define a server component anywhere in your Payload config, we must go back to the server to render it, on demand, unless it was part of the initial page load. For example, when a field's admin.condition evaluates to true, a custom server component may need to render in as a result.

Within the edit view, this means we go to the server to process every change, because that change potentially require rendering server components, regardless of whether it truly does.

Solution

So the question becomes: do we need to do go to the server for every user action? Why can't we determine at build-time exactly which actions require rendering, and only go the server for events that specifically require it?

That's exactly what this PR explores.

Imagine a world where we make a server roundtrip only when a server component needs rendering. We only need to go to the server if the user's action requires a fresh server component to render, eliminating all unnecessary requests.

In this world, default Payload components, as well as custom client components, can be rendered on the spot, immediately. Even projects that heavily use server components at the field level, we only need to go to the server if the user's action requires a fresh server component to render.

Aside from the network and bandwidth savings, UX improvements of fulfilling user actions instantaneously is a huge win. For example, newly added array rows would no longer lazy load.

Technical Constraints

Easier said than done, however, as this approach has implications to custom client components, and more. This is largely due to the server/client boundary in RSC frameworks like Next.js, which restrict us from passing any non-serializable JSON across that boundary, including React components, functions, etc.

Getting around this constraint is not necessarily the problem, as we have existing patterns that achieve a similar goal through the "import map". For background, in v3 we established a fully Node-safe Payload config by stripping React components out of the config itself, and instead referencing them via file paths. When we need to render a component, we reference it via import map.

For example:

import type { Text Field } from 'payload'

const MyField: TextField = {
  type: 'text',
  admin: {
    components: {
      Field: './PathToMyComponent.tsx' // This shipped in Payload v3
    }
  }
}

So in theory, we could extend this approach and create a "client import map" that could be sent through the boundary. By doing this, we would have full access to the custom client components on the client, and can render them without going to the server.

Where this breaks down is this: other properties required of form state would need the same "file path" treatment in order to run on the client.

For example, fields are often behind conditional logic, require validations, or default values. To avoid going to the server to run these functions, they would need to exist on the "client import map", and consequently, be defined as file paths...not great.

For example:

import type { Text Field } from 'payload'

const MyField: TextField = {
  type: 'text',
  admin: {
    components: {
      condition: './PathToMyCondition.tsx',
    }
  }
}

This is obviously inferior to writing basic functions inline. We don't want to require file paths for all admin properties.

In the future, it's possible we could do this programmatically, however, this is a bundling problem more than anything. For example, we cannot simply lift these functions out into an external file. Things outside of the function's closure, like imports, local variables, etc., cannot easily be carried over. This is where the 'use client' directive shines.

Other implications

Here's a generally comprehensive list of all properties affected by this change:

  • validate would lose its event arg, and would only run server-side validations on submit
  • admin.validate would be a new property used to run client-side validations on change, defined as a file path
  • admin.condition would now run client-side, defined as a file path
  • defaultValue would force the field into server rendering even without custom components, as it requires a req arg and must run async

Looking ahead, we should also consider a future "realtime API" that might have implications to this work. For example, it alone would invoke a high volume of requests, one for each event, effectively reversing some of the gains of this PR.

This also ties into another broader point to me made, which is that form state network requests are not a big deal. For example, each request contains a relatively small payload, typically not exceeding a few KB of transfer.

Demo

Before:

Screen.Recording.2026-05-01.at.3.23.14.PM.mp4

After:

Screen.Recording.2026-05-01.at.3.14.25.PM.mp4
- Move seed-doc deletion into finally block so script-loop failures don't leak the seed
- Type formState as FormState (not Record<string, any>); same for invoke param
- Hoist SEED_TITLE constant and pass the actual seed doc through to invoke
- Pin benchmark assertion to toBe(24) so regressions actually fail the test
- Drop unused filename declaration
- Convert invoke to single-object parameters per CLAUDE.md
- Document when renderFieldsCalls / totalServerCallTimeMs get populated
…n values

Path-valued admin.condition is a string identifier resolved on the client.
Server-side beforeChange and iterateFields previously assumed it was always a
function, which would throw at runtime. Guard with typeof === 'function' and
treat non-function values as passing until Phase 5 wires server resolution.
…ipeline

Adds an end-to-end test proving the Phase 3 primitives compose for a
path-valued admin.condition: import-map emission routes the entry to the
client map only, the on-disk module loads via dynamic import, and
evaluateConditions returns the expected visibility map. Includes a
@payloadcms/ui/forms/evaluateConditions subpath export to mirror the
renderFields pattern.
…ield errors

Phase 8 wired `runAdminValidate` to fire on every formState change. The
validator function evaluates regardless of whether the user has interacted
with the field, so empty required fields surfaced an error tooltip on
mount (e.g. "must be at least 3 characters" on an empty Handle field
before any keystroke).

Filter the per-change loop by `formState[path]?.isModified`. Untouched
fields are skipped — save-time validation still gates submit through the
legacy server pipeline. After the user types into a field, the modified
flag flips true and the validator runs as before.

Updates two existing specs to set `isModified: true` on the field paths
under test, and adds a new spec that locks the new gating contract.
Phase 11 review surfaced the question of whether the existing
`traverseFields` utility could replace `walkSchema`. Investigation
found they cover different concerns: `traverseFields` is data-shaped
(mutates ref, designed for traversing data with optional schema fill)
and produces dot-notation paths without wildcard segments;
`walkSchema` is schema-only and emits wildcard-aware paths
(`orders.lineItems.*.sku`) that `componentIndex.componentsAt`
relies on for array-row specialization.

Add a JSDoc paragraph to `walkSchema` documenting the distinction at
the source so future contributors don't try to merge the two without
preserving wildcard-path semantics.
…e resolution

Resurrect classifyComponentKind (Phase 1.2, removed in 08dac6c) to deterministically
classify component refs by inspecting the source file's first non-comment line for the
`'use client'` directive. Per the user direction: "Unless a component explicitly has
'use client' directive, we should treat it as a true server component" — anything else
(no directive, unreadable file, unresolvable path) defaults to 'server'.

Improvements over the original:
- Resolve baseDir-relative paths (`./`, `../`) against `config.admin.importMap.baseDir`
  so Payload's standard component-ref shape works without absolute paths.
- Fan out `.js` → `.tsx`/`.ts`/`.jsx` extension fallbacks so refs targeting the import
  map's `.js` shape can read the on-disk source.
…classifier

Add `kind: 'client' | 'server'` to IndexedComponent. Sanitize-time prepass walks the
schema to collect unique component paths, classifies each in parallel via
classifyComponentKind (using `config.admin.importMap.baseDir`), then rebuilds the
componentIndex with a closure-backed classifier so every IndexedComponent entry
carries its kind. The kind tag projects automatically to clientConfig.componentRefs
and forwards through DecideCallTarget so the UI dispatch can split client vs. server
targets without any runtime $$typeof inspection.
…check

Phase 9 onChange dispatch now splits decideCall targets by `target.kind` (carried
from the source-text classifier) rather than calling isReactClientComponent on the
resolved module. The previous heuristic was unreliable for "shared" modules whose
RSC bundling depends on Next's bundler heuristics rather than an explicit
'use client' directive — a component file without the directive could mount
client-side without firing renderFields, depending on what other modules it
imported.

Server-kind targets short-circuit to renderFields without ever touching the import
registry. Client-kind targets still resolve through the registry to mount locally;
on registry miss, missing export, or resolver throw they fall through to server
render defensively.

Also adds a new `arrays` test collection (with explicit client + server custom
Field components) so the form-state suite exercises both classification branches
end-to-end.
…Index

componentRefs (built by walkSchema) carry slug-prefixed paths like
'arrays.serverArray.*.text', but formState/visibility paths fed to
decideCall are collection-relative ('serverArray.0'). Without the strip,
componentsAt() segment-prefix match always failed, decideCall produced
zero targets, and structural-add events for custom-component fields
silently dropped — no renderFields dispatch, custom Field component
never rendered.

Same pattern Form already uses for adminConditionRefs/adminValidateRefs
(stripEntitySlugPrefix in Form/index.tsx). Wire it in at the Edit memo
that builds the client-side ComponentIndex.

Verified via test/form-state arrays collection: Add Server Array click
within heartbeat window now fires render-fields with
{path: serverArray.0.text, slot: Field}, ServerTextField renders on the
server (pid + hostname proof markers), default-array click is still a
no-op, client-array click mounts in-bundle without a server roundtrip.
…rver Field components

Three bugs surface together when an array adds a row whose inner field
has admin.components.Field set to a server component:

1. Slug mismatch: server-side componentIndex paths are entity-slug
   prefixed ('arrays.serverArray.*.text'), but client-side dispatch uses
   formState-relative paths ('serverArray.0.text'). The render-fields
   request landed on the server with slug-stripped paths, componentsAt()
   segment-prefix match failed, every target produced 'No registered
   component'. Fix on the client side: re-prefix paths going out, strip
   on the way back.

2. renderSingleField was too narrow: it built minimal serverProps without
   clientField, field schema, schemaMap, etc. Custom Field components
   that wrap a base @payloadcms/ui field (TextField, NumberField, etc.)
   crashed destructuring 'admin' from undefined. Replace it with a
   proper call into renderField (the same path used for initial form-
   state construction), wired through getSchemaMap + getClientSchemaMap
   so server custom fields receive everything the legacy renderer gives
   them. renderSingleField was unused outside renderFields, so it's
   removed.

3. renderField.tsx uses JSX without 'import React' — fine in Next.js
   where React is global, fatal in non-Next callers (vitest int tests,
   the new render-fields path through renderField). Add the React
   import. The eslint-unused-vars override is intentional; tsc preserves
   the binding so the classic JSX runtime can resolve
   'React.createElement'.

Verified in browser: Add Server Array fires render-fields with
{path: arrays.serverArray.0.text, slot: Field}, server executes
ServerTextField (pid + hostname proof), default Field never replaces it
on heartbeat. All form-state int tests (37) and unit tests (1409 + 39
react) pass.
…d Field

Phase 6/8 made ADD_ROW default `isLoading: false` to kill the
indefinite shimmer flash that was happening for default + client-
component rows after the buildFormState roundtrip was removed. That fix
was correct for the common case but exposed a different flash for rows
whose schema includes a server-classified custom Field: the row mounted
showing the default Field, then visibly swapped in the ServerTextField
(or similar) once the renderFields response landed.

Restore the loading state — but only when warranted:

- Form derives a `serverFieldArrayPaths` Set from `config.componentRefs`,
  filtered to entries with kind='server' and slot='Field'. Each path
  contributes its array container prefix (everything up to the first
  '*' segment), slug-stripped to align with formState keys.
- `addFieldRow` sets `hasServerField` on the ADD_ROW dispatch by
  consulting that Set.
- ADD_ROW reducer maps `hasServerField` -> `row.isLoading`. Default
  rows still skip the flag and mount with no shimmer.
- MERGE_RENDERED_FIELDS clears `isLoading` on the deepest array row that
  owns each rendered path (walks segments right-to-left to find the
  rightmost numeric segment).

Verified in browser: Add Server Array shows two ShimmerEffect blocks
(row label + field body) for ~780ms, then ServerTextField swaps in with
the node-only hostname/pid markers. Add Default Array stays flash-free.
Tests: 1409 unit + 39 react + 37 form-state int all pass.
…ed rows

Repeated Add Server Array clicks were stamping every previously-added
row with a fresh React component (and a fresh server-side timestamp).
Three independent bugs combined to do this:

1. mergeServerFormState wholesale-spread incoming fields onto current
   state. Heartbeat form-state responses (renderAllFields: false) include
   each existing field with customComponents.<slot>: undefined when the
   server didn't re-render that slot — the spread wiped previously-
   rendered Field components on the client. Merge customComponents per-
   slot instead: incoming non-undefined slots overwrite, undefined slots
   fall back to whatever the client already has.

2. deriveRealizedFromFormState skipped any slot whose value was nullish.
   Edit's onChange feeds decideCall a deepCopyObjectSimpleWithoutReact-
   Components projection, which leaves customComponents keys in place
   but sets each value to undefined. Realization read those as 'not
   realized', decideCall produced a render target for every existing
   row on the next ADD_ROW, and renderFields stamped them all with new
   components. Treat key presence as realization. Spec updated.

3. render-fields response landed customComponents into form state via
   MERGE_RENDERED_FIELDS without setting lastRenderedPath. The next
   form-state heartbeat saw lastRenderedPath !== current path, decided
   the field needed re-rendering, and the server re-ran the custom
   component for every row in the array. Stamp lastRenderedPath in the
   reducer so heartbeats treat already-rendered paths as stable.

Verified: 5 sequential Add Server Array clicks now yield 5 unique
timestamps in row order, and earlier rows' timestamps don't change as
new rows are added. Tests: 1409 unit + 39 react + 37 form-state int.
Phase 13's client-mount fast-path resolves a custom Field component
from the import registry and instantiates it with bare {path, schemaPath}
props. That worked for components that ignore field config, but any
wrapper around a base @payloadcms/ui field (TextField, NumberField,
etc.) crashed destructuring 'admin' from undefined — TextField requires
`field: ClientField` to read admin.placeholder, admin.className, etc.

The legacy server-render path bakes the right props in via
`RenderServerComponent.clientProps` (field, path, permissions, readOnly,
schemaPath). Replicate the field lookup on the client by walking the
client entity config's nested fields, skipping numeric (row-index)
segments. Components without a name (row, collapsible) descend
transparently. If the path doesn't resolve (config drift, field added
mid-session), fall back to server render.

Verified: Add Client Array adds 3 rows in sequence, each with a unique
timestamp from the 'use client' component, no console errors. Tests:
1409 unit + 39 react + 37 form-state int.
The ADD_ROW path was rendering the default Field for new array/block
rows whose schema has a 'use client' custom component, then swapping in
the custom component a few hundred milliseconds later when the
debounced onChange dispatch + MERGE_RENDERED_FIELDS cycle completed.
The user sees a default text input flash, then the wrapped component
(timestamp, hostname banner, etc.) replaces it. Server-classified
custom Fields are unavoidable (the renderFields roundtrip is async),
but client components live in the bundle and can mount with the first
paint of the new row.

Plumbing:

- ClientImportRegistry gains `getCached(path)`, a sync read of the
  resolved value cache. Modules that haven't been awaited at least
  once still return null (callers fall back to the existing async
  resolve path).

- Form derives a per-array list of client-Field entries from
  componentRefs and pre-warms the registry for each one in a mount
  effect. By the time the user clicks Add Row, every relevant module
  is in the value cache.

- `findClientFieldAtPath` extracted from Edit into a shared utility so
  Form can resolve the ClientField for each pre-mounted entry.

- `addFieldRow` looks up its array's client entries, sync-resolves the
  Component classes via `getCached`, builds React elements with
  field/path/schemaPath props, and dispatches them on ADD_ROW under a
  new `clientCustomComponents` action prop keyed by sub-path.

- ADD_ROW reducer writes the pre-mounted components into the new
  row's flat field-state entries with `lastRenderedPath` stamped (so a
  subsequent form-state heartbeat doesn't try to re-render them
  server-side and overwrite the client component).

Verified: Add Client Array shows the user's component within one
microtask of the click (no default-Field flash), four sequential rows
get unique timestamps, earlier rows stay stable, no console errors.
Tests: 1409 unit + 39 react + 37 form-state int.
…rm-state-v4

Phase 13 (and follow-on stability fixes) for the form-state v4 perf overhaul:

- Source-text 'use client' classifier replaces the runtime 76083typeof heuristic;
  componentIndex entries carry kind: 'client' | 'server' tag, dispatch reads
  it directly to split client-mounted vs server-rendered targets.
- Bridge entity-slug-prefixed componentIndex paths to slug-stripped
  formState/visibility paths in Edit dispatch (both directions).
- renderFields server function rewired through the legacy renderField path
  with full clientField/schemaMap plumbing so wrappers around base
  @payloadcms/ui field components don't crash; renderField imports React
  to support non-Next callers (vitest int).
- Loading state restored for new array/block rows whose schema has a
  server-classified custom Field; default + client rows still mount with
  zero flash.
- mergeServerFormState preserves customComponents per-slot, deriveRealized
  uses key presence, MERGE_RENDERED_FIELDS stamps lastRenderedPath — keeps
  earlier rows stable as new server-rendered rows are added.
- Client custom Field components mount synchronously on ADD_ROW via a new
  ClientImportRegistry.getCached + Form-mount pre-warm + addFieldRow sync
  resolution path. No default-Field flash for client rows.
… form

Phase 14: narrow the public API for admin.condition toward path-valued
references. Inline functions still type-check and still evaluate (via
the legacy server-side passesCondition path) so existing apps keep
working, but sanitize logs a one-shot deprecation warn per unique
inline condition so devs can migrate at their own pace.

Why: path-valued conditions bundle to the client and evaluate
synchronously per keystroke. Inline conditions can't bundle, so they
require a server roundtrip for every visibility check — one keystroke
of lag, network cost, and a flash of stale visibility on every change.
Mixing both forms in the same codebase is a footgun (devs assume
client-fast behavior and don't realize a particular condition is
secretly hitting the server).

What changes:

- New AdminConditionRef (string or { path, exportName? }) and
  AdminCondition (ref OR deprecated inline) types in payload core.
  Field admin.condition + UI field admin.condition both adopt
  AdminCondition. Both new types re-exported from payload index.
- sanitizeField walks each field's admin.condition; if it's a
  function, calls warnInlineAdminCondition(condition, schemaPath).
  Deduped via a WeakSet keyed on the function reference so a single
  helper used across many fields fires once.
- Demo: new test/form-state Conditions collection exercises three
  path-valued flavors — top-level boolean, top-level select, and
  sibling-scoped array row. All path-valued. No inline.

Migration is on a longer horizon: 64 inline admin.condition usages
exist across packages (queues, query-presets, plugin-redirects,
plugin-import-export, plugin-ecommerce, richtext-lexical, etc).
Tracked as a follow-up phase. Inline support stays through the
deprecation window.
…side Condition

The Phase 14 type widening introduced a new `AdminCondition` union name;
public surface is cleaner if `Condition` keeps its established meaning
(the inline function type) and the new path-valued shape is exposed as
its own primitive type `ConditionRef`. Field admin slots now declare
`condition?: Condition | ConditionRef` inline rather than via a wrapper
union, so existing `const myCond: Condition = (data) => ...` keeps
working and the path-valued option is opt-in via `ConditionRef`.

Also rename the test/form-state Conditions demo fields away from
scenario-flavored names (toggle, tier/beta) toward functional ones
(controller/dependent), so the demo reads as a pure exercise of the
visibility wiring rather than a recipe for any specific feature:

- topLevelCheckbox       -> dependentTextA
- topLevelSelect (B/C/A) -> dependentTextB + dependentNumberB
- rows.*.rowController   -> rows.*.dependentRowField

The new typing also lets the demo drop `as any` casts on the path
strings — `Condition | ConditionRef` already accepts a string.
Path-valued conditions on fields nested under arrays/blocks (e.g.
`array.*.field`) were not evaluating client-side: useClientCondition-
Visibility passed the wildcard fieldPath verbatim into evaluateConditions,
the visibility map ended up keyed by `array.*.field`, and WatchCondition
looked up by concrete path (`array.0.field`) — miss → fall through to the
server-driven passesCondition (one keystroke lag, stale state on first
paint).

Walk `data` along each ref's fieldPath whenever it contains a `*`
segment, emit one entry per array index encountered, and forward the
row-local data slice as `siblingData` so sibling-scoped conditions read
the right row. Non-wildcard refs go through unchanged.

Verified in browser against the new Conditions demo: Add Array row with
controller off keeps the dependent row field hidden immediately, toggling
the row controller flips visibility synchronously, and toggling the
top-level controller doesn't disturb row state.
…TODO on prop

The sanitize-time deprecation warn fired once per unique inline
condition function but with 64 inline usages across plugins/core that
still produces a substantial wall of text on dev startup. The signal
isn't actionable for app authors who don't own those plugins.

Drop the warn (and the WeakSet helper) and consolidate the
deprecation guidance into a single `@todo v4` JSDoc on the
`condition?: Condition | ConditionRef` prop in FieldAdmin. App authors
see it via TS hover; plugin maintainers see it during their own
review.
…y condition

Initial form-state build was eagerly rendering every field's custom Field
component (including server-rendered ones), even when the field's
admin.condition resolved false. The stale React element got baked into
`customComponents.Field`. When the user later flipped the condition
true, WatchCondition unhid the stale element and decideCall skipped
targeting the path (already-realized), so the server component never
got a fresh render — same timestamp as page load, no network request.

Two changes land the fix:

- iterateFields now resolves path-valued admin.condition refs via the
  runtime importMap and evaluates them server-side at form-state build
  time. (Previously the comment said "Treat as passing here until Phase
  5 wires server resolution" — Phase 14 wires it.) Inline functions
  still evaluate the same way they did before.
- addFieldStatePromise gates the renderFieldFn call on
  `passesCondition !== false`, so hidden fields land in form state
  without a custom-components bag. The visibility flip routes through
  decideCall.newlyVisible -> renderFields, producing a fresh element
  with a current timestamp.

Verified in browser against the new conditional server/client custom
Field test fixtures (test/form-state Conditions): initial paint has no
`#custom-server-text-field` in the DOM; flipping the condition true
fires render-fields and the element appears with a fresh timestamp.
Fields whose schema declares a server-classified custom Field (admin.
components.Field on a non-'use client' module) now render a ShimmerEffect
placeholder while the rendered React element is missing from form state
— rather than briefly flashing the default field underneath.

Two flows hit this path now:

1. Visibility flip: Phase 14 stopped pre-rendering custom Field
   components for fields with passesCondition=false. The first time the
   user flips the condition true, the renderFields roundtrip is in
   flight; without this change the field would mount as the default
   text input for ~one round-trip, then swap to the user's component.
2. ADD_ROW: top-level row-cell server Fields had the same gap, but the
   per-row isLoading flag only covered the row body, not individual
   field cells.

Plumbing:

- New PendingServerFieldPaths provider exposes a wildcard-aware matcher
  built once per Form mount from the slug-stripped server-Field entries
  in componentRefs. Patterns can include '*' segments so an
  array.*.field entry matches array.0.field at lookup without
  materializing per-row paths.
- Form wraps its tree with PendingServerFieldPathsProvider alongside
  the existing VisibilityMapProvider.
- RenderField checks the matcher whenever customComponents.Field is
  undefined; if the path matches a pending server-Field pattern,
  render a ShimmerEffect instead of falling through to the default
  field renderer.

Tests: 1409 unit + 39 react + 37 form-state int.
- RenderField's pending-server-Field shimmer now sits inside
  WatchCondition. Without that gate, a hidden conditional field still
  paints a shimmer (the matcher fires on schema presence, not on
  visibility).
- test/form-state Conditions: drop the 'use client' directive from the
  path-valued condition modules. Path-valued conditions are isomorphic
  — iterateFields invokes them server-side at form-state build, the
  client registry invokes them per keystroke. A 'use client' module
  resolved server-side throws "Attempted to call X from the server but
  X is on the client". JSDoc on showWhenChecked documents the
  expectation for future condition authors.

Verified: condition flip on a server-Field-backed text field shows
shimmer at t+6ms, fresh server render lands at t+~300ms, no default-
field flash. Tests: 1409 unit + 39 react + 37 form-state int.
… Fields

ADD_ROW for an array whose row schema contained any server-classified
custom Field set `row.isLoading=true`, freezing the entire row in a
ShimmerEffect until `MERGE_RENDERED_FIELDS` arrived. That worked for
non-conditional server Fields, but for fields gated by an
admin.condition that resolves false on the new row,
`decideCall` correctly produces no target (no render needed for a
hidden field) — so `MERGE_RENDERED_FIELDS` never fires for the row
and the shimmer sticks forever.

Phase 14's per-cell shimmer (RenderField + PendingServerFieldPaths,
gated by WatchCondition) already covers the right granularity:
- non-conditional server-Field cell → cell shimmers until renderFields
  lands;
- conditional server-Field cell → cell stays hidden until the
  condition flips, then shimmers, then renders.

Drop the row-level mechanism: ADD_ROW always mounts with
`isLoading: false`, the `hasServerField` action prop is removed, and
the `serverFieldArrayPaths` memo + addFieldRow plumbing in Form go
with it.

Verified: Add Row on the Conditions array shows the row immediately,
its dependent conditional field stays hidden (no row freeze); flipping
the row controller reveals the dependent text field at +30ms; top-level
visibility flip on the conditional server custom Field still produces
shimmer + fresh server render. Tests: 1409 unit + 39 react + 37 form-
state int.
Structural-add for an array row produced render targets for every
custom Field under the new row regardless of admin.condition. The
renderFields handler doesn't re-check condition, so it baked a stale
React element into customComponents.Field for fields that should have
been hidden. When the user later flipped the condition true,
WatchCondition unhid the stale element and decideCall's realized check
skipped re-targeting the path — the server component never got a fresh
render and the user saw the page-load timestamp instead of a current
one.

Filter the consider() step on next.visibility.get(component.path) so
hidden targets are dropped before they reach the renderFields call.
Default-visible fields (no admin.condition) have undefined in the map
and pass through unchanged.

Verified against test/form-state Conditions: Add Row leaves the
row's conditional server custom Field absent from the DOM; flipping
the row controller produces a +30ms shimmer and a fresh server
timestamp at ~+330ms — matching the top-level conditional behavior.
…e-v4

Phase 14 — path-valued conditions hard-enforced for v4 (with deprecated
inline still type-allowed) plus a deep correctness pass on conditional
custom Field components:

- New ConditionRef type (string | { path, exportName? }) sits alongside
  Condition (function). FieldAdmin.condition?: Condition | ConditionRef
  with @todo v4 marker on the inline arm.
- iterateFields server-side now resolves path-valued admin.condition
  refs via the runtime importMap and evaluates them, so the initial
  form-state build no longer pre-renders custom Field components for
  fields that should be hidden.
- addFieldStatePromise gates renderFieldFn on passesCondition !== false
  so hidden fields land in form state without a stale customComponents
  bag.
- decideCall additionally drops targets whose next.visibility is false
  (covers structural-add for an array row whose schema has conditional
  custom Fields — without this, every row mount eagerly rendered the
  hidden cell).
- useClientConditionVisibility expands wildcard fieldPaths
  (array.*.field) into concrete row paths against live data, with the
  row object as siblingData.
- New PendingServerFieldPaths provider exposes a wildcard-aware matcher
  built from componentRefs; RenderField uses it to render a ShimmerEffect
  (gated by WatchCondition) for any path expecting a server custom Field
  whose payload hasn't arrived yet — covers both the visibility-flip and
  ADD_ROW windows. Default + client-bundled rows still mount with zero
  flash.
- Drop the row-level isLoading=true plumbing for arrays containing
  server custom Fields. Per-cell shimmer makes it redundant, and it was
  actively wrong for conditional cells where renderFields legitimately
  doesn't fire.
- New test/form-state Conditions collection demonstrates the three
  path-valued condition flavors (top-level boolean, top-level select,
  sibling-scoped array row) plus conditional custom server/client Field
  components at both top level and inside an array row, all
  isomorphic-condition modules (no 'use client' directive).

Verified via browser sequences: condition flip + Add Row produce
shimmer at +30ms, fresh server-rendered timestamp at +330ms; row no
longer freezes when its schema includes a conditional server Field;
multiple sequential rows hold unique stable timestamps.
Per-function unit specs (29 files across packages/payload, packages/ui,
test/form-state) provided diminishing value relative to the existing
broad coverage (test/form-state/int.spec.ts at 1149 lines, e2e.spec.ts
at 783 lines) plus the Next.js bundling + admin-panel-load surface,
which catches the vast majority of regressions in this layer.

Removed:
- packages/payload/src/admin/buildImportMaps.spec.ts
- packages/payload/src/config/{buildComponentIndex,checkConditionBundleability,classifyComponentKind,clientAdminConditionRefs,clientAdminValidateRefs,clientComponentRefs}.spec.ts
- packages/payload/src/config/__fixtures__/* (orphaned)
- packages/payload/src/fields/config/client.spec.ts
- packages/ui/src/fields/FieldError/index.spec.tsx
- packages/ui/src/forms/Form/{fieldReducer,useClientAdminValidateErrors,useClientConditionVisibility}.spec.{ts,tsx}
- packages/ui/src/forms/{decideCall,deriveRealized,detectStructural,diffVisibility,evaluateConditions,runAdminValidate}.spec.ts
- packages/ui/src/forms/withCondition/WatchCondition.spec.tsx
- packages/ui/src/providers/{AdminValidateErrors,ClientImportRegistry,VisibilityMap}/index.spec.tsx
- packages/ui/src/utilities/{clientImportRegistry,createComponentIndexFromRefs}.spec.ts
- test/form-state/{adminValidate,benchmark,conditions,renderFields,skipValidation}.int.spec.ts

Kept: test/form-state/int.spec.ts + e2e.spec.ts as the load-bearing
coverage. Tests: 1308 unit + 21 react + 21 form-state int still pass.
Future correctness regressions in this area should be caught by new
e2e flows added per surface, not by per-function unit specs.
After removing the per-function spec files added during v4, the
react-testing infrastructure they required is no longer used:

- @testing-library/dom (10.4.1)
- @testing-library/react (16.3.2)
- jsdom (29.1.0)

Also drops the matching scaffolding:
- root package.json: `test:unit:react` script
- vitest.config.ts: `unit-react` project (jsdom env, *.spec.tsx
  include) — the remaining `unit` and `int` projects cover what's
  left
- tsconfig.base.json: `**/*.spec.tsx` exclude (no .spec.tsx files
  left in the repo)
- packages/ui/package.json: 3 of 4 internal exports added for the
  deleted specs (`./utilities/clientImportRegistry`,
  `./forms/evaluateConditions`, `./forms/runAdminValidate`). The
  fourth (`./utilities/renderFields`) stays — `@payloadcms/next`
  consumes it via the `render-fields` server-action handler.

Tests: 1308 unit + 21 form-state int still pass.
Phase 15 — trim PR surface: drop the per-function spec files added
across packages/payload, packages/ui, and test/form-state during the
v4 work, plus the test-only scaffolding they pulled in.

- 29 spec files + orphaned __fixtures__ deleted (-2780 lines).
  test/form-state/int.spec.ts (1149 LOC) and e2e.spec.ts (783 LOC)
  remain as the load-bearing coverage; admin-panel-load + Next.js
  bundling catch most regressions in this layer, and any
  surface-specific cases get new e2e flows rather than per-function
  unit specs.
- @testing-library/{dom,react}, jsdom, the test:unit:react script,
  the unit-react vitest project, the .spec.tsx tsconfig exclude, and
  3 of 4 internal package exports added for the deleted specs all
  removed.
- @payloadcms/ui ./utilities/renderFields export retained —
  @payloadcms/next imports it for the render-fields server-action
  handler.

Tests: 1308 unit + 21 form-state int.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

1 participant