Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-form): deliver authored FormSection.visibleWhen to the renderer in all four layouts - #6239
Merged
Merged
Conversation
…derer in all four layouts `@objectstack/spec` declares `FormSection.visibleWhen` and the spec bridge carries it, but every plugin-form layout renders a section header as a virtual `section-divider` pseudo-field without copying the predicate onto it — so on the object-view chain the key was declared, mapped, carried, then dropped one hop before anything evaluated it. `visibleWhen` fails OPEN, so the section simply rendered unconditionally with no diagnostic. Two hops were dropping it, and only the second was on the card: 1. `ObjectForm` rebuilds each section KEY BY KEY when it delegates to Split/Drawer/Modal, and `ModalForm`'s own `groups` map does it again — a key these maps do not copy never reaches the layout at all. The split map's own comment already recorded the hazard: "a key it doesn't copy is silently dropped — exactly how `visibleOn` once vanished here." 2. The six `section-divider` synthesis sites across the four layout files. The renderer half already worked: `form.tsx` runs every pseudo-field through `resolveFieldRuleState` with the host predicate scope bound (#6010) before the `section-divider` branch, so a divider carrying a predicate hides like a field. Measured scope: `section-divider` is a presentational ROW and the renderer holds no association between it and the fields that follow, so a false predicate removes the HEADING and leaves the section's fields rendering. The console renderer drops the whole `<section>`. That divergence is filed separately and pinned honestly here rather than implied away.
…in words The fix makes previously-inert authored metadata start hiding sections. Because `visibleWhen` fails OPEN, an app that authored a section predicate saw the section render and had no way to tell the rule was switched off — so this lands as a visible regression for anyone who grew used to that state, even though it is the intended ADR-0089 contract rather than a new capability.
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 25, 2026 04:10
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 25, 2026
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 freeto 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.
Fixes#6111
Gate union run on
4ecd4dc25(the final commit on this branch).What was broken
@objectstack/specdeclaresFormSection.visibleWhen, this repo's spec bridge maps it (packages/react/src/spec-bridge/bridges/form-view.ts:250), andRecordFormPage/resolveFormViewLayoutwire wholesectionsobjects into the layouts. Every plugin-form layout then renders a section header as a virtualsection-dividerpseudo-field, and none of them copied the predicate onto it. On the object-view chain the key was declared, mapped, carried, and dropped one hop before anything evaluated it.visibleWhenfails OPEN, so the section rendered unconditionally with no diagnostic.The card's diagnosis was incomplete — the key was dropped at TWO hops
This is the part that would have made a six-site fix still ship inert.
ObjectFormrebuilds each section key by key when it delegates, sovisibleWhennever reached three of the four layouts regardless of what the synthesis sites did:ObjectForm.tsx:296SplitForm{name,label,description,columns,fields,pane,className,gridClassName}ObjectForm.tsx:331DrawerFormcollapsible/collapsed/classNamevariant)ObjectForm.tsx:388ModalFormModalForm.tsx:612sections→groups{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." Both hops are repaired.The six
section-dividersynthesis sites, re-derived onf66072d1b(the card was measured ona100f77) — count is still six:ObjectForm.tsx:1206,ModalForm.tsx:676,ModalForm.tsx:709,DrawerForm.tsx:555,DrawerForm.tsx:614,SplitForm.tsx:347.@object-ui/typesgains the matchingObjectFormSection.visibleWhendeclaration.PM mechanism assumptions — all five verified on
f66072d1bform-view.ts:250, unchanged.RecordFormPage.tsx:256(sections: formDef.sections) andrecordFormNavigation.ts:142(layout.sections = formView.sections) both pass whole objects untouched. ButObjectForm's delegating remaps DO drop it — see the table above.form.tsxalready evaluates it — 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). That file'ssectionSurfacerow hand-authors{ name: 'pay', type: 'section-divider', visibleWhen }and its DENIED block asserts it resolves false ⇒ hidden, withcurrent_userbound from the host scope. The mechanism is generic:form.tsx:1991if (!ruleState.visible) return nullruns every pseudo-field throughresolveFieldRuleStatewithpredicateScopebound before thesection-dividerbranch at:2014.config-panel-renderer.tsx:291is a different contract — ✅ confirmed and left alone. It callssection.visibleWhen(draft)— a function predicate, not the authored CEL key.Three of the five gates fail, including the load-bearing one.
undefined(an argument-binding omission at the evaluator). Same narrative, different defect, different fix. The rubric explicitly excludes "同关键词/同子系统" — a shared contract narrative is exactly that trap.packages/plugin-form(+ a type inpackages/types). #6110:apps/console/src/components/FormPage.tsx+packages/plugin-form/src/WizardForm.tsx— two packages, one of them a different app. Not one changeset, not one queue slot./f/:slugroute has no authenticated principal, and it shares the call site with the authed route, so whatcurrent_usermeans there is an open contract question. Folding would wash an unadjudicated decision into an adjudicated PR — precisely what this gate exists to prevent.Order, and why this one goes first. This card is mechanical and its fix shape is fully pinned by existing evidence (the renderer already evaluates; the #6010 pin proves it by running). #6110 needs a maintainer ruling before any code. Landing this first also makes #6110's console-side gap observable rather than theoretical — with section predicates live on the object-view chain, the console route's divergent binding becomes a reproducible difference instead of a reading. And #6110's
WizardFormhalf lands inpackages/plugin-form, this wave's exclusive surface for this seat, so serialising hands it a clean tree instead of a race.⛔ #6110 is untouched and unclaimed. No edit to
FormPage.tsxorWizardForm.tsx— so the flagged overlap with the i18n agent's census inapps/consoledid not arise.Tests — every case asserts HIDDEN, never merely SHOWN
packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx— 16 tests, all green.visibleWhenfails OPEN, so a section that renders is what you get when the predicate is TRUE, when it never arrived, and when it faulted. An assertion that a section IS shown distinguishes none of them and is green on unfixed code. The deliverable is therefore the DENIED block: the heading is absent while a false predicate is authored on the section. Each of the five layout rows mounts a named layout through the entry the product actually uses (modal/drawer/split viaObjectForm, exercising both hops; plusModalFormmounted directly, which is theresolveFormViewLayoutshape that never passes throughObjectForm).Per-site ablation — direction predicted BEFORE running, and confirmed
Predicted: reverting one synthesis site turns only that layout's DENIED row red, in the SHOWN direction (the heading comes back, fail-open); every ALLOWED and FAULTED row stays green everywhere. Each leg proved the mutation on disk (anchor uniqueness bounded to the file, removed text grepped to zero,
git diff --statprinted), restored undertrap … EXIT INT TERM, and ended withgit diff HEAD --statempty.ObjectForm.tsx2 failed | 14 passed—ObjectForm — stacked simple sections+ the measured-scope caseAssertionError: expected <span …></span> to be null✅ SHOWNModalForm.tsx(g.visibleWhen)2 failed | 14 passed— both ModalForm rows (delegated and direct)DrawerForm.tsx(explicit-sections arm)1 failed | 15 passed—DrawerForm — via ObjectForm delegationSplitForm.tsx1 failed | 15 passed—SplitForm — via ObjectForm delegationThe first DrawerForm attempt used an anchor that occurs twice in that file; the guard refused to mutate rather than hit the wrong arm, and it was re-run with an anchor bounded to the explicit-sections span. Recording that because a silent wrong-site mutation would have read as a successful ablation.
No rebuild was needed for these legs and that is a property of the setup, not an omission: the pin imports
../ObjectForm— the source file being edited — so the mutation reaches the running code directly. The four reds are themselves the proof it did.Stated plainly, as asked:
Alwaysreadiness/control assertion in every row — the un-gated sibling heading renders either way.That is 11 of 16 assertions green on a full revert. The 5 that would go red are the DENIED rows plus the measured-scope case, and they are the entire deliverable of this file.
Measured scope — what this does NOT deliver
The predicate gates the section's header row.
form.tsx:2014treatssection-divideras presentational and holds no association between it and the fields that follow, so a false predicate removes the heading and the section's fields keep rendering. The console renderer (apps/console/src/components/FormPage.tsx:1819) drops the whole<section>, fields included.That divergence is real. It is pinned honestly by the
measured scope: a hidden section still renders its FIELDScase rather than implied away — that assertion turning red is the signal the follow-up landed. It is not fixed here because there is no free slot to stamp the section predicate into (a field'svisibleWhencarries the object-level rule and itsvisibleOncarries the authored per-field view predicate — both already spoken for), and composing two predicate sources into one CEL string inside a layout is exactly the consumer-side tolerance contract-first forbids. The fix belongs in the renderer as a real section grouping. Filed as #6236.The tabbed arm (
ModalForm.tsx:638fieldTabs) — the measurement, not a silent skip. It synthesises no divider at all, so there is nothing to copy onto; andFormFieldTab(packages/types/src/form.ts:825-849) declares exactlykey/label/description/fields/containerClass— no predicate slot — whileform.tsx:1309/1335/1402never evaluate one for a tab. It cannot carry a predicate without a new public contract, and the semantics are undecided in a way that matters:form.tsx:1301force-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". Filed as #6237.TabbedForm/WizardFormhave the same limitation, so 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.Behaviour change — in the changeset, in words
.changeset/6111-formsection-visiblewhen.md,minor(nevermajor). It states explicitly that previously-inert authored metadata will start hiding sections; that fail-open is why nobody noticed and why this lands as a felt regression; that it is the intended ADR-0089 contract rather than a new capability; and it tells authors to audit anysections[].visibleWhenbefore upgrading.Verification
predicate-scope-parity-6010.test.tsx(assumption 4, by running)Tests 15 passed (15)sectionVisibleWhen-6111.test.tsx(new)Tests 16 passed (16)vitest run packages/plugin-form/Test Files 63 passed (63) · Tests 633 passed (633)vitest run packages/components/src/renderers/form/Test Files 52 passed (52) · Tests 346 passed (346)type-check@object-ui/types (tsc --noEmit×3 projects)EXIT=0type-check@object-ui/plugin-form (tsc --noEmit×2 projects)EXIT=0turbo run lint --filter=@object-ui/plugin-form --filter=@object-ui/types --forceTasks: 3 successful, 3 total(uncached)check-changeset-no-major.mjsNo changeset declares a major bump.check-changeset-presence.mjs6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.check:spec-symbols1304 files scanned against 4959 spec export namescheck-control-bytes.mjsOK (scanned 5140 tracked text file(s))Gate set derived by enumerating each CI job's own step list (
ci.ymltype-check/test/changeset-check,lint.yml,changeset-guard.yml,changeset-presence.yml,control-bytes.yml), not top-level script names. Exit codes captured before any pipe.One declared narrowing.
pnpm type-checkisturbo run type-check, which drives the full 72-task repo build; it was killed at the container's ~10-minute foreground cap with@object-ui/plugin-designer#buildOOM-killed (exit 137) — a repo-scale run CI owns. It was replaced by invokingtsc --noEmitdirectly in each of the two changed packages, which is the same compiler over the same sources with the build graph removed.pnpm lintisturbo run lint(per-packageeslint .), so the lint above is the complete CI lint unit for both changed packages, not a narrowing. Everything else in the farm runs on CI exactly once.⛔ Left as draft — not marked ready, not enqueued, no auto-merge. The PM lands it.
Generated by Claude Code