perf: remove form state network requests#16446
Draft
jacobsfletch wants to merge 83 commits intomainfrom
Draft
Conversation
- 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.
…ComponentIndex and buildImportMaps
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-componentresponse 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.conditionevaluates 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:
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:
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:
validatewould lose itseventarg, and would only run server-side validations on submitadmin.validatewould be a new property used to run client-side validations on change, defined as a file pathadmin.conditionwould now run client-side, defined as a file pathdefaultValuewould force the field into server rendering even without custom components, as it requires areqarg and must run asyncLooking 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