Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
e64f7e3
test(form-state): add call-frequency benchmark harness
jacobsfletch Apr 28, 2026
161db2d
test(form-state): address review on benchmark harness
jacobsfletch Apr 28, 2026
15cb801
feat(payload): unified component index with kind classification
jacobsfletch Apr 28, 2026
9fe88c5
fix(payload): keep componentIndex out of client config and pin edge c…
jacobsfletch Apr 28, 2026
1f03c26
feat(payload): bundler-driven kind classifier for component index
jacobsfletch Apr 28, 2026
8bf1f12
refactor(payload): polish kind classifier per code review
jacobsfletch Apr 28, 2026
8cd6cd8
feat(payload): emit two import maps (server + client) at config build
jacobsfletch Apr 28, 2026
945ffbe
refactor(payload): polish two-map artifact per code review
jacobsfletch Apr 28, 2026
c90cda0
feat(ui): client-side import registry for admin runtime
jacobsfletch Apr 28, 2026
a2b3a67
refactor(ui): polish client import registry per code review
jacobsfletch Apr 28, 2026
08dac6c
refactor(payload): drop source-text use-client classifier; rely on bu…
jacobsfletch Apr 28, 2026
560e0d6
test(payload): cover wildcard-path and zero-component cases in buildI…
jacobsfletch Apr 28, 2026
4fb5cec
feat(payload): types for renderFields request/response
jacobsfletch Apr 28, 2026
666571f
feat(ui): implement narrow renderFields server function
jacobsfletch Apr 28, 2026
88222ed
refactor(ui): tighten renderFields args per code review
jacobsfletch Apr 28, 2026
c398d43
feat(next): expose renderFields as a server action
jacobsfletch Apr 28, 2026
d991e3e
feat(payload): build-time check that conditions are client-bundleable
jacobsfletch Apr 28, 2026
72f190d
feat(ui): client-side condition evaluator
jacobsfletch Apr 28, 2026
ef37198
feat(ui): visibility map React context
jacobsfletch Apr 28, 2026
7d2d4d9
fix: guard server-side admin.condition invocation against non-functio…
jacobsfletch Apr 28, 2026
2ffd361
test(form-state): integration test for path-valued client condition p…
jacobsfletch Apr 28, 2026
a19cfc6
feat(payload): add AdminValidateRef type for client-side validators
jacobsfletch Apr 29, 2026
8e91bc7
feat(payload): support object-form admin.validate refs in buildImport…
jacobsfletch Apr 29, 2026
f41b21e
test(payload): regression cases for admin.validate import-map collection
jacobsfletch Apr 29, 2026
a81df6e
test(payload): preserve admin.validate refs in client field projection
jacobsfletch Apr 29, 2026
fa5ad60
feat(ui): client-side runAdminValidate via import map
jacobsfletch Apr 29, 2026
51ee787
chore(ui): export createClientImportRegistry as a public subpath
jacobsfletch Apr 29, 2026
295a2e4
test(form-state): integration test for path-valued admin.validate pip…
jacobsfletch Apr 29, 2026
8d25780
feat(ui): detectStructural helper for array add/remove diffing
jacobsfletch Apr 29, 2026
e2f6771
feat(ui): diffVisibility helper for visibility-trigger detection
jacobsfletch Apr 29, 2026
2abf2de
feat(ui): decideCall algorithm for renderFields dispatch
jacobsfletch Apr 29, 2026
c21a314
chore(test): add jsdom vitest project + backfill provider render tests
jacobsfletch Apr 29, 2026
e73c9cc
refactor(ui): converge runAdminValidate ref resolution with parsePayl…
jacobsfletch Apr 29, 2026
39de072
feat(payload): emit admin-condition refs to runtime importMap and cli…
jacobsfletch Apr 29, 2026
c018283
feat(ui): hydrate clientImportRegistry from runtime importMap
jacobsfletch Apr 29, 2026
2488a44
feat(ui): wire client-side conditions into Form via VisibilityMapProv…
jacobsfletch Apr 29, 2026
ffbfd2a
feat(payload): emit admin-validate refs to clientConfig
jacobsfletch Apr 29, 2026
379e25f
feat(ui): wire runAdminValidate into Form via AdminValidateErrorsProv…
jacobsfletch Apr 29, 2026
25b07f1
test(form-state): assert skipValidation gates custom validate on edit…
jacobsfletch Apr 29, 2026
a84b08e
feat(payload): emit componentRefs to clientConfig for client-side dec…
jacobsfletch Apr 29, 2026
e027e8a
feat(ui): client componentsAt wrapper sharing server filter logic
jacobsfletch Apr 29, 2026
5d4a4f8
feat(ui): deriveRealizedFromFormState helper for client-side dispatch
jacobsfletch Apr 29, 2026
4e1c697
feat(ui): MERGE_RENDERED_FIELDS reducer action and renderFields client
jacobsfletch Apr 29, 2026
89c189a
feat(ui): client-side dispatch swap via decideCall + renderFields
jacobsfletch Apr 29, 2026
c57afcc
refactor(ui): dispatch swap polish + dev-mode warn for unresolved cli…
jacobsfletch Apr 29, 2026
0c5c30a
feat(payload): emit importMap.client.js with 'use client' directive
jacobsfletch Apr 29, 2026
2e499ad
fix(next): pass clientImportMap separately to RootProvider; keep impo…
jacobsfletch Apr 29, 2026
46fdc40
fix(test/app): import both importMap.js and importMap.client.js into …
jacobsfletch Apr 29, 2026
5a5f4b0
fix(ui): use ref for editSessionStartTime to prevent stale-closure he…
jacobsfletch Apr 29, 2026
47314c4
feat(ui): drop per-edit stale-data check from Edit view
jacobsfletch Apr 29, 2026
e073f49
fix(ui): default ADD_ROW to isLoading false to remove shimmer flash
jacobsfletch Apr 29, 2026
e80120b
feat(ui): WatchCondition consumes VisibilityMap for path-valued condi…
jacobsfletch Apr 29, 2026
4687ada
feat(ui): FieldError surfaces admin.validate errors live during edit
jacobsfletch Apr 29, 2026
8dd880c
feat(ui): split dispatch targets by kind in Edit onChange
jacobsfletch Apr 29, 2026
d0e8974
feat(ui): gate admin.validate on field.isModified to skip untouched-f…
jacobsfletch Apr 29, 2026
afbb61a
refactor(payload): extract shared walkSchema helper consumed by build…
jacobsfletch Apr 29, 2026
62430bd
test(ui): render-test coverage for WatchCondition and FieldError cons…
jacobsfletch Apr 29, 2026
7eaeb81
docs(payload): explain why walkSchema is distinct from traverseFields
jacobsfletch Apr 29, 2026
044524e
feat(payload): source-text use-client classifier with baseDir-relativ…
jacobsfletch Apr 29, 2026
d09fcf0
feat(payload): tag componentIndex entries with kind from source-text …
jacobsfletch Apr 29, 2026
1145fc7
feat(ui): Edit dispatch reads kind tag instead of runtime \$\$typeof …
jacobsfletch Apr 29, 2026
1786f72
fix(ui): strip entity slug from componentRefs before client component…
jacobsfletch Apr 29, 2026
1194e77
fix(ui): make render-fields server function actually render custom se…
jacobsfletch Apr 29, 2026
552358b
fix(ui): show shimmer on new array/block rows awaiting server-rendere…
jacobsfletch Apr 29, 2026
4258560
fix(ui): keep earlier array rows stable when adding new server-render…
jacobsfletch Apr 29, 2026
7b0f124
fix(ui): pass ClientField to client-mounted custom Field components
jacobsfletch Apr 29, 2026
78b7bec
feat(ui): mount client custom Field components synchronously on ADD_ROW
jacobsfletch Apr 29, 2026
5ec86c7
Merge perf/form-state-v4-phase-13-source-text-classifier into perf/fo…
jacobsfletch Apr 29, 2026
f352b0c
feat(payload): introduce path-valued AdminCondition; deprecate inline…
jacobsfletch Apr 30, 2026
e0fe790
refactor(payload): drop AdminCondition rename; use ConditionRef along…
jacobsfletch Apr 30, 2026
f175f94
fix(ui): expand wildcard admin.condition paths per realized row
jacobsfletch Apr 30, 2026
8c18d10
refactor(payload): drop sanitize-time inline-condition warn; keep v4 …
jacobsfletch Apr 30, 2026
580c905
fix(ui): don't pre-render custom Field components for fields hidden b…
jacobsfletch Apr 30, 2026
40d35ba
feat(ui): show shimmer for fields awaiting server custom Field render
jacobsfletch Apr 30, 2026
5900dfd
fix(ui): wrap shimmer in WatchCondition; document isomorphic conditions
jacobsfletch Apr 30, 2026
f33959c
cleanup
jacobsfletch Apr 30, 2026
f648d4f
fix(ui): drop row-level isLoading for arrays containing server custom…
jacobsfletch Apr 30, 2026
1e7bd1f
fix(ui): skip decideCall targets whose visibility is currently false
jacobsfletch Apr 30, 2026
bd94d48
Merge perf/form-state-v4-phase-14-conditions-demo into perf/form-stat…
jacobsfletch Apr 30, 2026
ce37c09
cleanup
jacobsfletch Apr 30, 2026
0141199
chore: drop narrow spec files added during form-state v4 work
jacobsfletch Apr 30, 2026
06b6f4d
chore: drop test-only deps + scaffolding from form-state v4 work
jacobsfletch Apr 30, 2026
5d2336b
Merge perf/form-state-v4-phase-15-spec-cleanup into perf/form-state-v4
jacobsfletch Apr 30, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(ui): expand wildcard admin.condition paths per realized row
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.
  • Loading branch information
jacobsfletch committed Apr 30, 2026
commit f175f94f89bccc03bd03fa2d782b69a8d7ca0d8c
90 changes: 86 additions & 4 deletions packages/ui/src/forms/Form/useClientConditionVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,30 @@ export function useClientConditionVisibility(
if (resolved.size === 0) {
return new Map<string, boolean>()
}
const fields = Array.from(resolved.entries(), ([path, condition]) => ({
condition,
path,
}))
// Phase 14: expand wildcard fieldPaths (e.g. `rows.*.dependentRowField`)
// into concrete row paths against the live document data. WatchCondition
// looks up by concrete path (`rows.0.dependentRowField`); without this
// step the visibility map carries only wildcard keys, the lookup misses,
// and the field falls back to the server-driven `passesCondition` which
// lags one keystroke behind.
const fields: Array<{
condition: ConditionFn
path: string
siblingData?: unknown
}> = []
for (const [fieldPath, condition] of resolved) {
if (!fieldPath.includes('.*.')) {
fields.push({ condition, path: fieldPath })
continue
}
for (const expanded of expandWildcardPath(fieldPath, data)) {
fields.push({
condition,
path: expanded.concretePath,
siblingData: expanded.siblingData,
})
}
}
return evaluateConditions({
context: { blockData, operation, user },
data,
Expand All @@ -144,3 +164,65 @@ export function useClientConditionVisibility(
// in the caller). resolved changes only when the import map changes.
}, [blockData, data, operation, resolved, user])
}

type ExpandedPath = {
concretePath: string
/**
* The data slice immediately containing the leaf field — the row object for
* an array-row field, or the parent row when nested deeper. Forwarded to
* `evaluateConditions` so sibling-scoped conditions read the right row.
*/
siblingData: unknown
}

/**
* Walks `data` along `fieldPath`, expanding each `*` segment into one entry
* per array index it encounters. Returns concrete paths plus the sibling data
* slice the leaf field belongs to. Non-array values at a wildcard segment are
* silently skipped — keeps the helper safe against partial form state.
*/
function expandWildcardPath(fieldPath: string, data: unknown): ExpandedPath[] {
const segments = fieldPath.split('.')
const out: ExpandedPath[] = []
walk(segments, 0, data, [], out)
return out
}

function walk(
segments: string[],
index: number,
current: unknown,
resolvedSegments: string[],
out: ExpandedPath[],
): void {
if (current == null) {
return
}
if (index === segments.length - 1) {
out.push({
concretePath: [...resolvedSegments, segments[index]].join('.'),
siblingData: current,
})
return
}
const segment = segments[index]
if (segment === '*') {
if (!Array.isArray(current)) {
return
}
for (let i = 0; i < current.length; i++) {
walk(segments, index + 1, current[i], [...resolvedSegments, String(i)], out)
}
return
}
if (typeof current !== 'object') {
return
}
walk(
segments,
index + 1,
(current as Record<string, unknown>)[segment],
[...resolvedSegments, segment],
out,
)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'

import type { Condition } from 'payload'

export const showWhenChecked: Condition = (data) => {
return Boolean((data as { showConditionalFields?: boolean })?.showConditionalFields)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'

import type { Condition } from 'payload'

export const showWhenRowChecked: Condition = (_data, siblingData) => {
return Boolean((siblingData as { showConditionalFields?: boolean })?.showConditionalFields)
}

This file was deleted.

This file was deleted.

68 changes: 11 additions & 57 deletions test/form-state/collections/Conditions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,6 @@ import type { CollectionConfig } from 'payload'

export const conditionsSlug = 'conditions'

/**
* Demo collection exercising the three path-valued condition flavors
* supported in Payload v4 form-state. Field names are deliberately
* functional (`controller` / `dependent`) rather than scenario-flavored
* so the demo reads as a pure exercise of the visibility wiring.
*
* 1. `topLevelCheckbox` (controller, boolean) → `dependentTextA`
* 2. `topLevelSelect` (controller, enum) → `dependentTextB`,
* `dependentNumberB`
* 3. `rows.*.rowController` (sibling-scoped) → `rows.*.dependentRowField`
*
* Inline `admin.condition: () => boolean` is no longer demonstrated. All
* conditions reference an importable module so they bundle to the client
* and evaluate synchronously.
*/
export const ConditionsCollection: CollectionConfig = {
slug: conditionsSlug,
admin: {
Expand All @@ -28,65 +13,34 @@ export const ConditionsCollection: CollectionConfig = {
type: 'text',
},
{
name: 'topLevelCheckbox',
name: 'showConditionalFields',
type: 'checkbox',
label: 'Top-level checkbox controller (boolean)',
defaultValue: false,
label: 'Show conditional fields?',
},
{
name: 'dependentTextA',
name: 'conditionalTextField',
type: 'text',
label: 'Dependent text A — visible when topLevelCheckbox is true',
label: 'Visible when `showConditionalFields` is true',
admin: {
condition: './collections/Conditions/conditions/showWhenCheckboxOn.js#showWhenCheckboxOn',
condition: './collections/Conditions/conditions/showWhenChecked.js#showWhenChecked',
},
},
{
name: 'topLevelSelect',
type: 'select',
label: 'Top-level select controller (enum)',
defaultValue: 'optionA',
options: [
{ label: 'Option A', value: 'optionA' },
{ label: 'Option B', value: 'optionB' },
{ label: 'Option C', value: 'optionC' },
],
},
{
name: 'dependentTextB',
type: 'text',
label: 'Dependent text B — visible when topLevelSelect === optionB',
admin: {
condition: './collections/Conditions/conditions/showWhenSelectIsB.js#showWhenSelectIsB',
},
},
{
name: 'dependentNumberB',
type: 'number',
label: 'Dependent number B — visible when topLevelSelect === optionB',
admin: {
condition: './collections/Conditions/conditions/showWhenSelectIsB.js#showWhenSelectIsB',
},
},
{
name: 'rows',
name: 'array',
type: 'array',
label: 'Per-row controller / dependent (sibling-scoped condition)',
labels: { singular: 'Row', plural: 'Rows' },
fields: [
{
name: 'rowController',
name: 'showConditionalFields',
type: 'checkbox',
label: 'Row controller',
defaultValue: false,
label: 'Show conditional fields?',
},
{
name: 'dependentRowField',
name: 'conditionalRowField',
type: 'text',
label: 'Dependent row field — visible when rowController is true',
label: 'Visible when `showConditionalFields` is true in this row',
admin: {
condition:
'./collections/Conditions/conditions/showWhenRowControllerOn.js#showWhenRowControllerOn',
'./collections/Conditions/conditions/showWhenRowChecked.js#showWhenRowChecked',
},
},
],
Expand Down
26 changes: 10 additions & 16 deletions test/form-state/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,12 @@ export interface Array {
export interface Condition {
id: string;
title?: string | null;
topLevelCheckbox?: boolean | null;
dependentTextA?: string | null;
topLevelSelect?: ('optionA' | 'optionB' | 'optionC') | null;
dependentTextB?: string | null;
dependentNumberB?: number | null;
rows?:
showConditionalFields?: boolean | null;
conditionalTextField?: string | null;
array?:
| {
rowController?: boolean | null;
dependentRowField?: string | null;
showConditionalFields?: boolean | null;
conditionalRowField?: string | null;
id?: string | null;
}[]
| null;
Expand Down Expand Up @@ -475,16 +472,13 @@ export interface ArraysSelect<T extends boolean = true> {
*/
export interface ConditionsSelect<T extends boolean = true> {
title?: T;
topLevelCheckbox?: T;
dependentTextA?: T;
topLevelSelect?: T;
dependentTextB?: T;
dependentNumberB?: T;
rows?:
showConditionalFields?: T;
conditionalTextField?: T;
array?:
| T
| {
rowController?: T;
dependentRowField?: T;
showConditionalFields?: T;
conditionalRowField?: T;
id?: T;
};
updatedAt?: T;
Expand Down