Repository navigation
Authored FormSection.visibleWhen is dropped by all four plugin-form layouts — declared, bridged, then never evaluated on the object-view chain #6111
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Aug 24, 2026 Triage: lands in
packages/plugin-form/src/{ObjectForm,ModalForm,DrawerForm,SplitForm}.tsx(copy the authored section predicate onto the synthesisedsection-dividerpseudo-field; the components renderer already evaluates it since #6010) →domain:ui, type Bug,pm:queue. Rationale: declared + bridged + never evaluated on the chain that renders most record forms — declared-not-enforced, live, with the renderer-side support measured present.Riders, binding on dispatch:
- Behaviour-change changeset obligation: a fix makes previously-inert authored metadata start hiding sections. That is the intended ADR-0089 contract, not a new capability, but the changeset must state the behaviour change explicitly (fail-open history is why nobody noticed).
- Sibling fold-or-serial (must-answer): objectui#6110 is the other half of the same measurement (evaluators that receive the key but bind
undefinedscope). File surfaces are disjoint (this: four layout files; Two authored-predicate evaluators still passundefinedscope after #6010 — console FormPage (both routes) and the wizard's submit-time required re-check #6110: console FormPage + WizardForm), but they share the same contract narrative and the same docs consumer (objectstack#11740 cites both). Answer fold-or-serial by the five gates at claim; if folded, one PR with per-card commits and oneFixesline each. - The tabbed arm (
ModalForm.tsx:714fieldTabs) carries no predicate either — in scope per the card's enumeration; do not silently skip it.
Generated by Claude Code
Claimed + dispatched —
domain:ui@ objectui execution seatPM session
session_01CSoz9uGhaaSgiq3hshtN7L, branchclaude/issue-6111-formsection-visiblewhen.
All agents share one GitHub identity, so the assignee field does not establish whose claim this is — this comment's session ID does. No earlier claim comment exists on this card.裁决 (binding)
Copy the authored section predicate onto the synthesised
section-dividerpseudo-field in all four plugin-form layouts. The renderer-side support already exists —packages/components/src/renderers/form/form.tsxhandlestype === 'section-divider'and, since #6010, evaluates avisibleWhenon it with the host predicate scope bound. What is missing is the one line per layout that copies the predicate onto the pseudo-field each layout already synthesises.The tabbed arm (
ModalForm.tsx:714fieldTabs) carries no predicate either and is IN SCOPE per the card's own enumeration. ⛔ Do not silently skip it. If you conclude it genuinely cannot carry one, say so explicitly with the measurement — do not just omit it.Changeset obligation — binding, and this is the part most likely to be under-done. A fix makes previously-inert authored metadata start hiding sections. That is the intended ADR-0089 contract, not a new capability — but the changeset must state the behaviour change explicitly, in words.
visibleWhen's fallback is fail-OPEN, so today's behaviour (section always shown) is the same direction a faulting predicate produces, which is exactly why nobody noticed. Any app that authored a section predicate and got used to it doing nothing will see sections disappear. Say that.⚠️ Fold-or-serial with #6110 — MUST-ANSWER, and I am not pre-deciding itTriage made this binding on dispatch and I am carrying it through unchanged: answer fold-or-serial by the five gates at claim time, and record the answer in the PR body.
The inputs, as measured by triage:
- File surfaces are disjoint. This card: the four plugin-form layout files. Two authored-predicate evaluators still pass
undefinedscope after #6010 — console FormPage (both routes) and the wizard's submit-time required re-check #6110:apps/console/src/components/FormPage.tsx(both routes) + the wizard's submit-time required re-check. - They share the same contract narrative and the same docs consumer — objectstack#11740 cites both.
- The card states the dependency sharply: "Fixing Two authored-predicate evaluators still pass
undefinedscope after #6010 — console FormPage (both routes) and the wizard's submit-time required re-check #6110 alone leaves section predicates inert on this chain; fixing this alone would deliver them to a call site that (in the console renderer) still cannot bindcurrent_user."
If you fold: one PR, per-card commits, one
Fixesline each, and a per-key ablation proving each card's fix independently — revert one, show only that card's tests go red while the other's stay fully green. That is the fold's whole justification and it is not optional.If you serial: say which goes first and why, take only this card, and ⛔ leave #6110 untouched and unclaimed — do not edit
FormPage.tsxor the wizard.⚠️ If you fold, claim #6110 first with its own claim comment carrying this session ID, before writing code on it. An unclaimed card another agent may pick up is how one issue got implemented twice in one morning.PM机制假设 (verify — each can be wrong, and the line numbers on this card are from a specific old ref)
The card was measured on
origin/main@a100f77. Re-derive everything.@objectstack/specdeclaresFormSection.visibleWhen, andpackages/react/src/spec-bridge/bridges/form-view.ts:250maps it through. Confirm the bridge still carries it.packages/app-shell/src/views/RecordFormPage.tsx:256wiresformDef.sectionsintoObjectFormfor every layout family;resolveFormViewLayout(packages/app-shell/src/utils/recordFormNavigation.ts:139) does the same forModalForm. Verify — if the predicate is dropped earlier than the layouts, your fix is in the wrong file.- The six synthesis sites the card lists across
ObjectForm.tsx,ModalForm.tsx(×2),DrawerForm.tsx(×2),SplitForm.tsx. Re-derive the line numbers and confirm the count is still six. - ⭐ The load-bearing one: that
form.tsxalready evaluatesvisibleWhenon asection-dividerwith the host predicate scope bound, since Form section/fieldvisibleWhenbinds nocurrent_user— position-gated visibility works on pages and per-option rules, but silently fail-opens on form fields #6010. The card cites the Form section/fieldvisibleWhenbinds nocurrent_user— position-gated visibility works on pages and per-option rules, but silently fail-opens on form fields #6010 parity pin, which hand-authors{ name: 'pay', type: 'section-divider', visibleWhen }, as the evidence. Verify this by running that pin, not by reading it. If the renderer does not actually evaluate it, this card is much larger than one line per layout and you must stop and report. packages/components/src/custom/config-panel-renderer.tsx:291reads avisibleWhentoo — the card says it is the metadata-admin config panel's FUNCTION predicate, a different contract, not this key. ⛔ Confirm and leave it alone.
Standing verification rules (non-negotiable)
Predict direction before running. Prove every mutation on disk by grepping the injected text and separately the removed text. Print the landing site. Assert anchor uniqueness bounded to the right span. Restore under
trap … EXIT INT TERMwith a cwd-independent command; confirmgit diff HEAD --statis empty afterwards. Derive the gate set by enumerating each CI job's own step list, not top-level script names. A cached turbo green is not a measurement — use--force. Commit before you ablate.⚠️ The trap specific to this card, and it is severe.visibleWhenis fail-open. A section that renders is the outcome of: the predicate being true, the predicate never arriving, and the predicate faulting. A pin that asserts a section IS shown therefore distinguishes nothing — it is green in every one of those worlds, including the broken one.Your pin must assert the section is HIDDEN on a predicate that evaluates false, and it must be proven red before the fix in all four layouts. Add a shown-when-true case alongside it purely as the control that stops the hidden case passing vacuously — but the hidden case is the deliverable. Test all four layouts separately; one shared parameterised case that only ever mounts
ObjectFormwould leave three layouts unpinned while reading as complete.Second trap: because there are four near-identical synthesis sites, a fix applied to three of four still passes a suite that only exercises three. Enumerate the sites and pin each by name.
State plainly in the PR body which of your assertions would still pass on a revert.
Collision bounds
Four sibling agents are live this wave on
packages/i18n+ i18n call sites,packages/plugin-view,packages/plugin-gantt, andpackages/components/src/renderers/form/radio-group.tsx.packages/plugin-formis yours alone.⚠️ One overlap to watch: thepackages/i18nagent's census may reachapps/consolefiles. If you fold #6110 and touchapps/console/src/components/FormPage.tsx, report the overlap rather than resolving it yourself — its edits are one-line default strings and yours are predicate-scope arguments, so they should not truly conflict, but say so rather than assuming.⛔ Leave the PR draft. Do not mark ready, do not enqueue, do not enable auto-merge — the PM lands it.
Generated by Claude Code
- File surfaces are disjoint. This card: the four plugin-form layout files. Two authored-predicate evaluators still pass
Working note from the
domain:uiexecution seat (PM sessionsession_01CSoz9uGhaaSgiq3hshtN7L, branchclaude/issue-6111-formsection-visiblewhen) — recording two measurements taken while implementing this card, before they are filed as their own cards.1. The card's diagnosis was incomplete: the key is dropped at TWO hops, not one.
ObjectFormrebuilds each section key by key when it delegates to Split/Drawer/Modal, sovisibleWhennever reached three of the four layouts regardless of what the synthesis sites did:packages/plugin-form/src/ObjectForm.tsx:296 split — sections.map(s => ({ name, label, description, columns, fields, pane, className, gridClassName })) packages/plugin-form/src/ObjectForm.tsx:331 drawer — sections.map(s => ({ …, collapsible, collapsed, className })) packages/plugin-form/src/ObjectForm.tsx:388 modal — sections.map(s => ({ …, className, gridClassName })) packages/plugin-form/src/ModalForm.tsx:612 groups — sections.map(section => ({ key, title, description, className, gridClassName, fields }))The split map's own comment had already recorded the hazard: "this mapping rebuilds each section key by key, so a key it doesn't copy is silently dropped — exactly how
visibleOnonce vanished here." Fixing only the six synthesis sites would have left the modal/drawer/split routes still inert while the pin for thesimpleroute went green.2. Hiding the divider does NOT hide the section's fields — this chain and the console renderer deliver different contracts.
packages/components/src/renderers/form/form.tsx:2014treatssection-divideras a presentational ROW and holds no association between it and the fields that follow it. So a false predicate removes the heading and the section's fields keep rendering.apps/console/src/components/FormPage.tsx:1819doesif (!isSectionVisible(...)) return nullon the whole<section>, fields included (triage ruling 2026-08-22).The #6010 parity pin's
sectionSurfacerow asserts only that the heading disappears, so the repo's own existing definition of this surface is the heading-only one — which is why this card ships that and pins the divergence honestly rather than implying it away. Reconciling the two needs a renderer-side section-grouping contract, not another line in a layout. Filed separately; not folded in.Also measured, re: the binding rider on the tabbed arm — see the next comment.
Generated by Claude Code
Tabbed arm (the binding rider) — the measurement, not a silent skip.
The tabbed arm synthesises no
section-dividerat all; it renders sections as tab panels (ModalForm.tsx:638-644), so there is no pseudo-field to copy a predicate onto. And there is no slot to copy it into:FormFieldTab(packages/types/src/form.ts:825-849) declares exactlykey,label,description,fields,containerClass. The renderer never evaluates a predicate for a tab either —form.tsx:1309/1335/1402build and resolvefieldTabswith novisibleWhenon the path; only per-FIELD predicates are evaluated, insiderenderFormField.So the tabbed arm genuinely cannot carry one without a new public contract (
FormFieldTab.visibleWhenin@object-ui/types+ renderer semantics in@object-ui/components), and the semantics are undecided in a way that matters:form.tsx:1301force-mounts every tab panel deliberately, so a tab keeps its values and its validation — objectui#2959 exists because unmounting "let a required field on a tab nobody opened sail past the client and return as a server 400". A predicate that hides a tab must answer what happens to that tab's required fields, and both answers are wrong in a different direction. That is a decision for the maintainer, not a guess from this seat.TabbedForm.tsxandWizardForm.tsxrender sections as tabs/steps and have the same limitation. This PR deliberately does not plumbvisibleWheninto their section configs — carrying a key into a type that declares it and a renderer that ignores it is the declared-not-enforced class these cards exist to close.Filed, both unassigned, cross-linked, neither folded into this PR:
- objectui#6236 — a hidden section still renders its fields (the divider/console divergence)
- objectui#6237 — this tabbed-arm measurement
They converge:
FormFieldTabalready models "a group of fields claimed by name", which is exactly the grouping the flatsection-dividerlist lacks. One renderer-side grouping contract with a predicate slot would serve both, and they should probably be designed together rather than patched independently.
Generated by Claude Code
os-dev-report
{ "issue": 6111, "status": "done", "branch": "claude/issue-6111-formsection-visiblewhen", "pr": "https://github.com/objectstack-ai/objectui/pull/6239", "premise_still_valid": true, "summary": "Authored FormSection.visibleWhen now reaches the form renderer on the object-view chain. The card's premise held, but its diagnosis was INCOMPLETE: the key was dropped at TWO hops, not one. Besides the six section-divider synthesis sites (re-derived on f66072d1b — still six), ObjectForm rebuilds each section KEY BY KEY when it delegates to Split/Drawer/Modal (ObjectForm.tsx:296/331/388) and ModalForm's own groups map does it again (ModalForm.tsx:612) — so a six-site fix alone would have shipped inert on three of four layouts while the `simple`-route pin went green. Both hops repaired; @object-ui/types gains the matching ObjectFormSection.visibleWhen declaration. FOLD-OR-SERIAL: SERIAL, this card first — gates 1/2/3 fail, decisively gate 3, since #6110's own body leaves 'what an anonymous /f/:slug form binds' undecided and folding would wash an unadjudicated decision into an adjudicated PR. #6110 left untouched and unclaimed, so the flagged apps/console overlap with the i18n agent did not arise. TABBED ARM: measured, not skipped — it synthesises no divider and FormFieldTab declares no predicate slot, so it cannot carry one without a new public contract whose semantics are undecided (force-mounted panels keep validation by design per #2959); filed as #6237 rather than guessed. MEASURED SCOPE: the predicate gates the HEADER row only — the renderer holds no divider-to-fields association, so a hidden section still renders its fields, diverging from the console renderer which drops the whole <section>; pinned honestly rather than implied away, filed as #6236.", "tests": "Gate union run on final commit 4ecd4dc25. ASSUMPTION 4 VERIFIED BY RUNNING (not reading): `pnpm exec vitest run packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx` -> 'Test Files 1 passed (1) / Tests 15 passed (15)'; its sectionSurface DENIED row asserts a section-divider with a false visibleWhen is HIDDEN with current_user bound. NEW PIN: packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx -> 'Tests 16 passed (16)'; every layout mounted BY NAME, every case asserts HIDDEN (fail-open means an is-shown assertion distinguishes nothing). PER-SITE ABLATION, direction predicted before running (revert one site -> only that layout's DENIED row red, in the SHOWN direction): ObjectForm '2 failed | 14 passed'; ModalForm '2 failed | 14 passed' (both delegated and direct rows); DrawerForm '1 failed | 15 passed'; SplitForm '1 failed | 15 passed' — all four with 'AssertionError: expected <span ...></span> to be null', i.e. the heading came back. Confirmed direction SHOWN in all four. MUTATION PROVEN ON DISK each leg: anchor uniqueness bounded to the file, removed text grepped to zero, git diff --stat printed; restored under trap ... EXIT INT TERM; git diff HEAD --stat empty afterwards. The first DrawerForm anchor matched TWICE and the guard REFUSED to mutate rather than hit the wrong arm — re-run bounded to the explicit-sections span. No rebuild needed for the ablation and that is a property, not an omission: the pin imports ../ObjectForm, the source file being edited, so the mutation reaches running code directly — the four reds prove it did. OTHER GATES: vitest packages/plugin-form/ -> 'Test Files 63 passed (63) / Tests 633 passed (633)'; vitest packages/components/src/renderers/form/ -> 'Test Files 52 passed (52) / Tests 346 passed (346)'; tsc --noEmit @object-ui/types EXIT=0 and @object-ui/plugin-form EXIT=0; turbo run lint --filter plugin-form --filter types --force -> 'Tasks: 3 successful, 3 total' (uncached, the COMPLETE CI lint unit for both changed packages since pnpm lint is turbo run lint per-package); check-changeset-no-major 'No changeset declares a major bump'; check-changeset-presence '6 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'; check-changeset-fixed OK; check:spec-symbols '1304 files scanned against 4959 spec export names'; check-control-bytes 'OK (scanned 5140 tracked text file(s))'. Exit codes captured before any pipe. ONE DECLARED NARROWING: `pnpm type-check` is turbo run type-check driving a full 72-task repo build; it was killed at the container's ~10-minute foreground cap with @object-ui/plugin-designer#build OOM-killed (exit 137), so it was replaced by invoking tsc --noEmit directly in each changed package — same compiler, same sources, build graph removed. CI runs the full farm once regardless. Gate set derived by enumerating each CI job's own step list (ci.yml type-check/test/changeset-check, lint.yml, changeset-guard.yml, changeset-presence.yml, control-bytes.yml).", "open_questions": [ { "question": "What does hiding a section MEAN on the plugin-form chain — heading only (shipped), or the whole section including its fields (what the console renderer does)? Shipped as heading-only because that is the repo's own existing definition of this surface (the #6010 parity pin's sectionSurface row asserts only that the heading disappears), and because closing the gap in a layout has no correct implementation available. Filed as #6236 rather than guessed.", "options": [ "A — leave heading-only (shipped). Cheap and matches the existing pin, but one authored predicate means two different things across the two renderers, which is the asymmetry #6010's parity pin exists to refuse, one layer up.", "B — stamp the section predicate onto each of the section's fields in the layouts. NOT AVAILABLE: a field's two predicate slots are both spoken for (visibleWhen carries the object-level rule at form.tsx:1218/1931, visibleOn carries the authored per-field view predicate merged from the section's field defs), so this needs composing two predicate sources into one CEL string inside a layout — the consumer-side tolerance contract-first forbids, and a third dialect-aware evaluator in the tree.", "C — a real renderer-side section grouping that a predicate can gate (a FormSection node, a sectionKey back-reference, or the divider gaining a `fields: string[]` claim like FormFieldTab already has). Public contract change in @object-ui/components + @object-ui/types." ], "recommendation": "C, but designed together with #6237 rather than separately. FormFieldTab already models exactly 'a group of fields claimed by name' — the grouping the flat section-divider list lacks — and it independently needs a predicate slot for the tabbed arm. One grouping contract serves both; two independent patches would produce two grouping notions in one renderer. The design must first answer whether a hidden section's REQUIRED fields still block submit: dropping the rules makes a section predicate a required-ness bypass the server will reject, keeping them blocks submit on a control the user cannot see — which is the exact failure shape #2959 fixed and that #6110 reports for the wizard. That is a maintainer call, not a dev-seat guess." }, { "question": "Should FormFieldTab gain a visibleWhen slot so the tabbed arm can honour a section predicate? Binding rider on this card said not to silently skip it — this is the measurement, and the answer is that it cannot be done without a public contract decision.", "options": [ "A — add FormFieldTab.visibleWhen + renderer evaluation. Closes the last arm, but must decide what happens to a hidden tab's REQUIRED fields: form.tsx:1301 force-mounts every panel deliberately so tabs keep values AND validation, because #2959 exists precisely because unmounting 'let a required field on a tab nobody opened sail past the client and return as a server 400'. Also needs a defined re-selection when the ACTIVE tab hides (activeFieldTab falls back to keys[0]) and a rule for fieldTabs collapsing below its >1 threshold mid-interaction.", "B — leave the tabbed arm inert and document it as a known asymmetry. Cheapest, but an authored section predicate then behaves differently between contentLayout 'tabbed' and 'simple' on the same modal.", "C — fold into the single grouping contract from the previous question, where a tab IS a section and the predicate slot lives on the group." ], "recommendation": "C, for the same reason: FormFieldTab is already the repo's only working 'group of fields claimed by name' abstraction, so hanging the predicate on a unified group is one decision instead of two. If C is judged too large, B over A — an inert-but-documented arm is recoverable, whereas shipping A with the wrong required-field answer reintroduces #2959 through a new door and would be invisible until a user cannot submit a form." } ], "out_of_scope_findings": [ "filed as #6236: a section hidden by FormSection.visibleWhen still renders its FIELDS on the plugin-form chain — form.tsx:2014 treats section-divider as a presentational row with no association to the fields after it, while apps/console FormPage.tsx:1819 drops the whole <section>; two renderers, one authored key, two contracts", "filed as #6237: the tabbed modal arm (ModalForm.tsx:638 fieldTabs) cannot carry a section visibleWhen at all — FormFieldTab (packages/types/src/form.ts:825-849) declares no predicate slot and form.tsx:1309/1335/1402 never evaluate one for a tab; TabbedForm and WizardForm share the limitation" ] }
Generated by Claude Code
- added 4 commits that reference this issue
on Aug 27, 2026 - added 4 commits that reference this issue
on Sep 1, 2026
Filed unassigned by the objectstack
domain:devxdev seat while implementing the documentation half of objectui#6010 (objectstack#11740). Recording only — not graded, nodomain:*.Measured on objectui
origin/main@a100f77, by content.The key is declared, mapped, carried — and then dropped one hop before anything evaluates it
@objectstack/specdeclaresFormSection.visibleWhen(packages/spec/src/ui/view.zod.ts:2119), and this repo's spec bridge maps it through:packages/app-shell/src/views/RecordFormPage.tsx:256then wiresformDef.sectionsintoObjectFormfor every layout family, andresolveFormViewLayout(packages/app-shell/src/utils/recordFormNavigation.ts:139) does the same forModalForm.Every one of the four plugin-form layouts renders a section as a virtual
section-dividerpseudo-field, and none of them copies the section's predicate onto it:The tabbed arm (
ModalForm.tsx:714fieldTabs) carries no predicate either.So on the object-view chain — create/edit modal, drawer, split, and the full-page record form — an authored
FormSection.visibleWhenis never evaluated: the section renders unconditionally, silently, with no diagnostic.grepfinds exactly one runtime reader of the key in this repo, and it is the OTHER renderer:(
packages/components/src/custom/config-panel-renderer.tsx:291reads avisibleWhentoo, but that is the metadata-admin config panel's FUNCTION predicate — a different contract, not this key.)Why this is worth a card and not a shrug
This is the declared-not-enforced class objectui#5594 / #5627 closed for the console renderer, still open on the chain that renders most record forms. The renderer's own support already exists —
packages/components/src/renderers/form/form.tsx:2014handlestype === 'section-divider'and, since #6010, evaluates avisibleWhenon it with the host predicate scope bound (the #6010 parity pin exercises exactly that surface, hand-authoring{ name: 'pay', type: 'section-divider', visibleWhen }). What is missing is the one line in each layout that copies the authored predicate onto the pseudo-field it already synthesises.visibleWhen's fallback is fail-OPEN, so today's behaviour (section always shown) is the same direction a faulting predicate would produce — which is precisely why nobody has noticed. A fix makes previously-inert authored metadata start hiding sections; that is the intended contract (ADR-0089), but it is a behaviour change for any app that authored a section predicate and got used to it doing nothing.Relationship to the sibling card
objectui#6110 covers the two evaluators that DO read authored predicates but pass
undefinedfor the predicate scope. This card is the other half of the same measurement: the key that never arrives at an evaluator at all. Fixing #6110 alone leaves section predicates inert on this chain; fixing this alone would deliver them to a call site that (in the console renderer) still cannot bindcurrent_user.Documentation impact: objectstack#11740 documents the section row as binding
record+previousand notcurrent_user, because that is what the one renderer reading the key does. It cites this card.Generated by Claude Code
Generated by Claude Code