Skip to content

fix(components): bind current_user on form section/field visibleWhen predicates - #6037

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-6010-form-field-predicate-scope
Aug 24, 2026
Merged

fix(components): bind current_user on form section/field visibleWhen predicates#6037
yinlianghui merged 1 commit into
mainfrom
claude/issue-6010-form-field-predicate-scope

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6010

What was wrong

current_user reached two of the three visibleWhen binding surfaces and not the third.
A page component or app/nav node got it (ExpressionProviderSchemaRenderer), a
per-option rule got it (resolveCascadingOptions(…, predicateScope)), and every
resolveFieldRuleState call in the form renderer passed undefined for scope — so a
form section or field predicate saw record and previous and nothing else.

resolveFieldRuleState passes true as the visibility fallback. So a gate naming
current_user did not hide the field from the people it named: it showed the field to
everyone
, with no signal beyond one deduped console.warn. Fail-open in the dangerous
direction — which is why this is graded a Bug and not an enhancement.

This restores what two accepted ADRs already declared:

  • ADR-0068 D1"a predicate authored against any one form evaluates identically"
  • ADR-0089 D1"runtime record surfaces bind record + current_user"

⚠️ This changes user-visible behaviour

Predicates that silently failed open on form fields and sections now evaluate, and a
rule resolving false now hides a field that is visible today. A host who authored
a visibleWhen naming current_user, saw the field render, and concluded the rule was
permissive was looking at a broken rule, not a permissive one — and that field will now
hide. The changeset states this in those terms and is marked minor on
@object-ui/components.

Two things deliberately do not change: a genuinely unbound root still fails open (only
evaluated-and-false hides), and visibleWhen remains presentation rather than access
control.

The pin is the deliverable

packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx
runs one authored predicate text through five binding surfaces in three modes.
The defect exists precisely because two surfaces got the scope and one did not, so no
single-surface assertion can express the contract.

surfacedeniedallowedfaulted
page component / app-nav node visibleWhenhiddenshownshown
per-option visibleWhen (select options)prunedkeptkept
form SECTION visibleWhenhiddenshownshown
form FIELD visibleWhenhiddenshownshown
form FIELD visibleOn (ADR-0089 D2 alias)hiddenshownshown
  • allowed is the counter-probe and the half that matters — "the predicate is now
    evaluated" is otherwise satisfiable by hiding everything, a worse bug than fail-open and
    invisible to the denied column alone.
  • faulted pins existing fail-open behaviour, which is what makes the denied column mean
    evaluated-and-false rather than could not be evaluated.

Reverse verification, direction predicted before running

Reverting the scope argument on one of the three call sites (the render-path one in
renderFormField) was predicted to turn red exactly the denied rows for form SECTION
and form FIELD, in the shown direction, leaving every other row green. Measured:
2 failed | 13 passed (15), both failures AssertionError: expected true to be false, on
those two rows. The mutation's landing site was printed rather than assumed (line 1990,
with two lines of context), both the injected marker and the removed text were grepped
separately, the restore ran under trap … EXIT INT TERM, and git diff HEAD --stat was
empty afterwards.

A bounded in-place fix, declared

The two visibleOn sites in form.tsx receive the same scope. ADR-0089 D2 folds
visibleOn into visibleWhen at parse, so binding one scope for the canonical spelling
and another for its deprecated alias would have reproduced this exact defect one spelling
over. Same defect class, same file, same gate family — pinned as its own row in the table
above.

The synthesised legacy condition: { field, equals } predicate keeps undefined: it is
generated from a structured object and can only ever name a record dot field reference,
so there is no authoring path by which it could reference current_user.

One structural change, not a one-liner

const predicateScope = usePredicateScope() moved ~75 lines up, above
readonlyFieldNames. The card's hypothesis — "pass the predicateScope already in hand at
:1276 to the three calls" — does not hold as written for the first call site: that memo
factory runs synchronously during render, so a predicateScope declared below it is in
the temporal dead zone there, and reading it would have been a ReferenceError on first
render rather than a missing binding. The hook is still called unconditionally and exactly
once per render; only its position among this component's hooks moved.

predicateScope joins the dependency arrays of both hooks that consume it, matching the
precedent already in this file for resolveCascadingOptions.

Gates

All measured on 5f7796c44 (git rev-parse --short HEAD at the time of the runs; the
type-check and vitest runs were made on the working tree that became this commit
byte-for-byte, the eslint run after it).

gatejudgement line printed by the gateexit
pnpm --filter @object-ui/components type-check> @object-ui/components@17.6.0 type-checktsc --noEmit && tsc -p tsconfig.test.jsonTYPECHECK_EXIT=0
pnpm exec vitest run packages/components/src/renderers/form packages/core/src/evaluator --maxWorkers=2Test Files 65 passed (65) · Tests 725 passed (725)VITEST_EXIT=0
eslint . in packages/components398 files linted, errors: 0, 908 warningsESLINT_PKG_AFTER_EXIT=0

The dependency closure was built first (pnpm --filter '@object-ui/components^...' build,
BUILD_CLOSURE_EXIT=0) so the type-check reads rebuilt .d.ts rather than a stale or
absent one.

eslint was run over the whole package, not narrowed — 398 files, 0 errors. form.tsx
carried 60 warnings before this change and 60 after, with an identical rule breakdown
(no-explicit-any 51, react-refresh/only-export-components 7, no-unused-vars 2),
measured by writing the base content to the same path so config resolution is identical.
No --no-inline-config.

Line-reference delta from the card

Re-derived on this branch's merge-base (53dc89db8) rather than taken from the card, whose
references came from an objectstack checkout of unknown vintage:

cardmeasured
form.tsx:12011201
form.tsx:12371237
form.tsx:12761276
form.tsx:14281428
form.tsx:19351945❌ off by 10
form.tsx:21292139❌ off by 10
fieldRules.ts:158158
optionRules.ts:103103
ExpressionProvider.tsx:59,7059,70

The true visibility fallback in resolveFieldRuleState was confirmed in that function's
own source, so the card's severity framing stands rather than inverting.

Out of scope, not folded in


Generated by Claude Code

Form section and field `visibleWhen` predicates now evaluate against the host
shell's predicate scope, the same bag per-option `visibleWhen` and the
page/app-nav node gate already receive.
All five `resolveFieldRuleState` / `evalFieldPredicate` call sites for AUTHORED
predicates in the form renderer passed `undefined` for `scope`, so a form-field
gate naming `current_user` named an unbound root — and the visibility fallback
is fail-open, so the gate showed the field to everyone instead of hiding it.
`usePredicateScope()` moves above `readonlyFieldNames`: that memo factory runs
synchronously during render, so the hook's historical position ~75 lines below
put it in the temporal dead zone at that call site. The hook is still called
unconditionally, once per render.
The synthesised legacy `condition: { field, equals }` predicate keeps
`undefined` — it can only ever name `record.<field>`.
Adds a parity pin running one authored predicate text through all five binding
surfaces in three modes: denied (hides), allowed (still renders), faulted
(still fails open).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3219.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-DZwtRQIp.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.22KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
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.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.12KB61.87KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
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)8.50KB2.88KB
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)52.40KB17.45KB
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

2 participants

@yinlianghui@claude