Skip to content

fix(plugin-form): deliver authored FormSection.visibleWhen to the renderer in all four layouts - #6239

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-6111-formsection-visiblewhen
Aug 25, 2026
Merged

fix(plugin-form): deliver authored FormSection.visibleWhen to the renderer in all four layouts#6239
yinlianghui merged 2 commits into
mainfrom
claude/issue-6111-formsection-visiblewhen

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6111

Gate union run on 4ecd4dc25 (the final commit on this branch).

What was broken

@objectstack/spec declares FormSection.visibleWhen, this repo's spec bridge maps it (packages/react/src/spec-bridge/bridges/form-view.ts:250), and RecordFormPage / resolveFormViewLayout wire whole sections objects into the layouts. Every plugin-form layout then renders a section header as a virtual section-divider pseudo-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. visibleWhen fails 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. ObjectForm rebuilds each section key by key when it delegates, so visibleWhen never reached three of the four layouts regardless of what the synthesis sites did:

sitemapwhat it dropped
ObjectForm.tsx:296SplitFormeverything not in {name,label,description,columns,fields,pane,className,gridClassName}
ObjectForm.tsx:331DrawerForm" (collapsible/collapsed/className variant)
ObjectForm.tsx:388ModalForm"
ModalForm.tsx:612sectionsgroups" ({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 visibleOn once vanished here." Both hops are repaired.

The six section-divider synthesis sites, re-derived on f66072d1b (the card was measured on a100f77) — count is still six: ObjectForm.tsx:1206, ModalForm.tsx:676, ModalForm.tsx:709, DrawerForm.tsx:555, DrawerForm.tsx:614, SplitForm.tsx:347.

@object-ui/types gains the matching ObjectFormSection.visibleWhen declaration.

PM mechanism assumptions — all five verified on f66072d1b

  1. Bridge still carries it — ✅ form-view.ts:250, unchanged.
  2. Not dropped earlier than the layouts — ✅ and this is where the card was wrong.RecordFormPage.tsx:256 (sections: formDef.sections) and recordFormNavigation.ts:142 (layout.sections = formView.sections) both pass whole objects untouched. But ObjectForm's delegating remaps DO drop it — see the table above.
  3. Six synthesis sites — ✅ re-derived, still six, line numbers above.
  4. form.tsx already evaluates it — verified by RUNNING, not reading.pnpm exec vitest run packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsxTest Files 1 passed (1) · Tests 15 passed (15). That file's sectionSurface row hand-authors { name: 'pay', type: 'section-divider', visibleWhen } and its DENIED block asserts it resolves false ⇒ hidden, with current_user bound from the host scope. The mechanism is generic: form.tsx:1991if (!ruleState.visible) return null runs every pseudo-field through resolveFieldRuleState with predicateScope bound before the section-divider branch at :2014.
  5. config-panel-renderer.tsx:291 is a different contract — ✅ confirmed and left alone. It calls section.visibleWhen(draft) — a function predicate, not the authored CEL key.

⚠️ Fold-or-serial with #6110 — answered: SERIAL, this card first

Three of the five gates fail, including the load-bearing one.

gateverdict
① same defect shape + same fixFAIL. This card: the key is never copied onto the pseudo-field (a data-plumbing omission in layout synthesis). #6110: the key arrives at an evaluator and the scope argument is 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.
② same package / areaFAIL. This: packages/plugin-form (+ a type in packages/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.
③ every member already adjudicated, none in the decision boxFAIL — decisive.#6110's own body says "Deciding what an anonymous form binds is part of this card": the public /f/:slug route has no authenticated principal, and it shares the call site with the authed route, so what current_user means there is an open contract question. Folding would wash an unadjudicated decision into an adjudicated PR — precisely what this gate exists to prevent.
④ each member independently verifiable✅ pass
⑤ dispatch names an exclusion list❌ not named

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 WizardForm half lands in packages/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.tsx or WizardForm.tsx — so the flagged overlap with the i18n agent's census in apps/consoledid not arise.

Tests — every case asserts HIDDEN, never merely SHOWN

packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx — 16 tests, all green.

visibleWhen fails 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 via ObjectForm, exercising both hops; plus ModalForm mounted directly, which is the resolveFormViewLayout shape that never passes through ObjectForm).

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 --stat printed), restored under trap … EXIT INT TERM, and ended with git diff HEAD --stat empty.

ablated siteresultdirection
ObjectForm.tsx2 failed | 14 passedObjectForm — stacked simple sections + the measured-scope caseAssertionError: expected <span …></span> to be null ✅ SHOWN
ModalForm.tsx (g.visibleWhen)2 failed | 14 passed — both ModalForm rows (delegated and direct)✅ SHOWN
DrawerForm.tsx (explicit-sections arm)1 failed | 15 passedDrawerForm — via ObjectForm delegation✅ SHOWN
SplitForm.tsx1 failed | 15 passedSplitForm — via ObjectForm delegation✅ SHOWN

The 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.

⚠️ Which assertions would still pass on a revert

Stated plainly, as asked:

  • Every ALLOWED row (5) — a true predicate shows the section, which is also what unfixed code does. Pure control.
  • Every FAULTED row (5) — an unbound root fails open and the section shows, identical before and after. It exists so the DENIED rows mean "evaluated and false" rather than "could not be evaluated".
  • The Always readiness/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:2014 treats section-divider as 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 FIELDS case 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's visibleWhen carries the object-level rule and its visibleOn carries 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; and FormFieldTab (packages/types/src/form.ts:825-849) declares exactly key/label/description/fields/containerClass — no predicate slot — while form.tsx:1309/1335/1402 never 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: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". Filed as #6237. TabbedForm/WizardForm have the same limitation, so this PR deliberately does not plumb visibleWhen into 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 (never major). 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 any sections[].visibleWhen before upgrading.

Verification

gateresult
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=0
type-check @object-ui/plugin-form (tsc --noEmit ×2 projects)EXIT=0
turbo 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 names
check-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-check is turbo 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#build OOM-killed (exit 137) — a repo-scale run CI owns. It was replaced by invoking tsc --noEmit directly in each of the two changed packages, which is the same compiler over the same sources with the build graph removed. pnpm lint is turbo run lint (per-package eslint .), 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.6 KB3266.6 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-BbQxhocZ.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.28KB114.58KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.32KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.39KB30.80KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authored FormSection.visibleWhen is dropped by all four plugin-form layouts — declared, bridged, then never evaluated on the object-view chain

2 participants

@yinlianghui@claude