Skip to content

fix(react): report a faulting disabled / disabledOn predicate instead of silently greying the control out - #6510

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6445-disabled-gate-diagnostic
Aug 26, 2026
Merged

fix(react): report a faulting disabled / disabledOn predicate instead of silently greying the control out#6510
os-support-ai merged 1 commit into
mainfrom
claude/issue-6445-disabled-gate-diagnostic

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6445

Six visibility legs in SchemaRenderer.tsx route through evaluateVisibilityPredicate and report a fault; the two enablement legs called evaluateConditionbare — the only uninstrumented predicate pair in the file. Both legs now pass EvaluationOptions.onFault (#6038's seam), so the fault the evaluator has already caught is reported at the same number of engine calls.

Verified on cbb77c304 (the head this PR was pushed at).

Scope: three files, one package

filechange
packages/react/src/SchemaRenderer.tsxevaluateEnablementPredicate helper beside its visibility sibling; both disabled legs route through it
packages/react/src/utils/visibilityDiagnostic.tsa second message on the existing reporter: PredicateGateKind + a GATE_KIND_COPY table
packages/react/src/index.tsthe new prefix constant + the type, beside the ones already exported

No throwOnError, no second evaluation, no reach into @object-ui/core.

The verdict does not move — deliberately

evaluateCondition answering true for an unevaluable predicate, and therefore greying the control out, is existing shipped behaviour, preserved. onFault is invoked for its side effect and its return value is ignored. Every case in the new suite pins the verdict beside the line (data-disabled-prop === 'true'), so a run that made the file green by flipping fail-soft would fail those pins instead.

The message decision (the card's design content)

Triage delegated the message text with the #3862/#3955 asymmetry as the constraint. The shipped copy is written about a gate that did not bite:

The node was treated as its safe default, which on this surface means the
gate did NOT bite - a predicate that cannot be evaluated reads on screen
exactly like one that said yes.

On this gate the safe default is the one that bites, so that sentence would tell an author the opposite of what is on their screen and send them hunting for a rendering bug. This gate prints:

[ObjectUI] An enablement predicate could not be evaluated - node "element:probe" (id: "save-btn")
disabled: "nosuchroot.locked == true"
Reason: Failed to evaluate expression "nosuchroot.locked == true": nosuchroot is not defined
The node was treated as its safe default, which on THIS gate is the one
that BITES: the node renders DISABLED - on screen, greyed out, refusing
input - and that is indistinguishable from a gate the author meant to
close. No pixel says a predicate failed, so this line is the only thing
that will ever name the one that did it.
Page-component predicates bind `record` (the row on a record page),
`current_user`, and page state as `page.<var>`. Check those roots and the
CEL syntax.

A second message, not a second reporter — the dispatch's caveat. One reporter, one dedupe Set, one severity, one test-only reset; a PredicateGateKind parameter selects the opening line and the consequence paragraph from a table indexed without a ?? fallback, mirroring how PredicateScopeTier already works in this module. The six visibility legs keep their bytes: a regression pin asserts the visibility line still starts and ends exactly as it did (#6487 landed those bytes hours ago).

The reporter's name is now narrower than the function. Kept: it is exported from the package entry and called from @object-ui/app-shell and @object-ui/components, so renaming would be a cross-package edit changing no behaviour, on a card scoped to one file's wiring. Stated in its docblock rather than left for a reader to notice.

The #6444 dedupe, proved in both directions — and a correction to the dispatch's framing

Correction, measured:warnedEvaluationFaults in ExpressionEvaluator governs the evaluator's built-in line only, and only when the caller supplies no onFaultreportEvaluationFault returns immediately after invoking the callback, and the CEL branch forwards { warn: false, onFault } to evalFieldPredicate, whose own passback is documented as firing on every fault. So reports from this new site do not flow through it. The rate limit that governs them is _warnedVisibilityPredicates in visibilityDiagnostic.ts, keyed (type, key, source). Both directions are pinned there anyway, because that Set is the one that matters here:

  • collapsing — 3 nodes with different ids and one source → 1 line; re-render → 1 line; a 200-row list → 1 line (and total console.warn calls = 1).
  • separating — a second distinct source → 2 lines; the same source on disabled and disabledOn → 2 lines; the same source on disabled and visibleWhen → 2 lines, in both mount orders. That last pair is the direction this new call site created: the two gates share one Set, and without it a key that collapsed them would look exactly like a working rate limit.

The reverse audit of the existing suite, both answers

Existing cells: nothing can now pass while measuring nothing. Every test file that exercises a disabled / disabledOn gate and watches the console was read: packages/components/.../page-header-predicate-dialect.test.tsx (its faulting-disabled cell goes through the page-header action gate and evalRowPredicate's own warn-once machinery — a different site, a different Set, and its fail direction is the opposite one: enabled, not disabled) and packages/app-shell/.../resolveActionParams.test.ts (literal disabled: false, no node gate). No existing cell asserts on console output while rendering a node-level faulting disabled, so none can read another cell's dedupe entry. Both files are in the run below and stayed green.

My own file: the hazard was real there, and is fixed.inProduction gets a fresh module graph per case, so core's warnedEvaluationFaults starts empty — but the development cells run on the static graph and share one Set for the whole file. A dev cell reusing another dev cell's fault source could have the evaluator's built-in line suppressed on its behalf, and the total-count pin would then read 1 on a build that reported the fault twice. Each dev cell now uses its own source (FAULT_BARE_DEV, FAULT_BARE_PARITY), with the reasoning written next to the constants.

Related: the dev-build console also carries a pre-existing false positive on these nodes — core's BASE_SCHEMA_RULES declares disabled "must be a boolean", so every expression-valued gate is reported as an invalid schema. Filed as #6505; the new suite subtracts it by name, never by count, so a doubled report of our own line could not hide inside the allowance.

Reverse verification (direction predicted before running)

Prediction: restoring the two bare evaluator.evaluateCondition(newSchema.disabled…) calls turns RED every report-count pin in groups 1, 2, 4 and 5, and leaves every verdict pin, all of group 3, and the visibility-copy regression guard GREEN.

Observed, with the mutation proved on disk (wired call sites 2 → 0, bare 0 → 2, blob hash moved) and restored by git checkout HEAD -- <abs path> against the pinned commit, verified by blob-hash equality and an empty git diff HEAD:

Test Files 1 failed | 2 passed (3)
Tests 15 failed | 55 passed (70)

15 red, exactly the predicted set: 5/5 group 1, 7/7 group 2, 2/3 group 4 (the visibility-copy guard stayed green — it does not depend on this wiring), 1/1 group 5. Groups 0 and 3 green, every verdict pin green, and both existing files green — which is the card restated: the existing suite could not see this defect, and the change moves the silence, not the answer.

(No rebuild step: the root vitest.config.mts aliases @object-ui/react and @object-ui/core to src, so the mutated source is what ran — nothing resolves through dist on this path.)

Verification

All runs through the shared validation lock (os-verify-lock.sh), narrowly scoped.

  • pnpm exec vitest run packages/react/ packages/components/src/__tests__/page-tabs-visible-when-fault-warning.test.tsx packages/components/src/__tests__/page-header-predicate-dialect.test.tsx packages/app-shell/src/providers/ExpressionProvider.visibleFaultDiagnostic.test.ts on cbb77c30462 files / 928 tests passed, VERDICT command-exit 0.
  • pnpm --filter @object-ui/react type-check (tsc --noEmit && tsc -p tsconfig.test.json) → exit 0. --listFiles confirms the new test file is inside the program (1 hit), so "typecheck is clean" covers it rather than merely excluding it.
  • pnpm --filter '@object-ui/react^...' buildVERDICT command-exit 0.
  • ESLint on the four changed/added files → 0 errors; the 17 no-explicit-any warnings in SchemaRenderer.tsx are pre-existing and none falls inside the changed hunks (718-778, 1018-1031). Narrowing evidence: file count read from --format json (4 linted, none ignored), and eslint.config.js configures no parserOptions.project / projectService and no import resolver, so no rule's verdict on an untouched file can depend on this diff. The repo-wide eslint . is CI's run.
  • Control-byte scan of every changed file: clean.

Filed rather than folded in


Generated by Claude Code

…tead of silently greying the control out
Six visibility legs in `SchemaRenderer` route through one reporter; the two
enablement legs called `evaluateCondition` bare — the only uninstrumented
predicate pair in the file — so a faulting `disabled` predicate was never
reported in any build, in any dialect that does not report on its own.
It is also the pair whose fail-soft answer bites: `evaluateCondition` answers an
unevaluable predicate with `true`, which on the negated visibility legs means
SHOWN and here means GREYED OUT. The user sees a control they cannot use and the
author has nothing to grep for.
Both legs now pass `EvaluationOptions.onFault` (#6038's seam), which hands back
the fault the evaluator has already caught — one engine call, no `throwOnError`,
no `__DEV__` split. The verdict is untouched: fail-soft is preserved
deliberately.
The reporter takes a second message rather than gaining a sibling: a
`PredicateGateKind` selects the opening line and the consequence paragraph, so
this gate says the safe default DID bite instead of reusing copy written about a
gate that did not. One reporter, one dedupe `Set`, one severity, one reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.8 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-CWQhDjZX.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)11.30KB4.28KB
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)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
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)60.76KB20.20KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review: ACCEPT at cbb77c304. Verified from the tree — no dev report came, the agent was killed by a container restart after pushing, so this review is taken entirely from the code.

The fence I set held, on the exact distinction it was set on. The dispatch said: a second message is in scope, a second reporter is a stop-and-report. You built the former. Measured: visibilityDiagnostic.ts still contains exactly oneconsole.warn, and the diff modifies it in place to take a gate argument rather than adding a second emit path. No new Set, no second reset. The header states it — "ONE reporter, ONE dedupe Set, ONE severity, ONE reset" — and the diff backs the claim rather than merely asserting it.

⭐⭐ The dedupe hazard was the thing most likely to eat this card, and you took it head-on.#6444 landed warnedEvaluationFaults on this seam hours earlier, so a new reporting site could have been silently swallowed by an existing entry and passed while measuring nothing. Your suite splits the proof explicitly into a COLLAPSING HALF and a SEPARATING HALF, and the separating half is the one that matters:

  • a second distinct source still warns — with the comment naming exactly why, "without this, a dedupe that suppresses everything looks identical"
  • the same source across KEYS (disabled vs disabledOn) is two lines
  • the same source across GATES (disabled vs visibleWhen) is two lines, one per gate

That last one is the specific swallowing case the dispatch named, closed directly. Proving only the collapsing direction would have been compatible with a mechanism that collapses everything — you proved both, which is what makes the green mean something.

Clause-② is present and correctly handled. Two new public exports on @object-ui/react's entry: the value UNRESOLVABLE_ENABLEMENT_PREFIX and the type PredicateGateKind. Both are justified on the line rather than slipped in — an app filtering these out of its console transport filters by the constant, and a caller of a public reporter has to be able to spell its arguments. Per this lane's standing order, a widened public surface goes at contract-review tier straight to the queue, so this does not wait on a human.

The polarity fence held too. The card described the fail-soft greying-out to motivate the diagnostic, not to change it, and the sibling family preserved fail-soft deliberately. Nothing here flips it — this adds a console line and nothing else about what the user sees.

The message text was yours to decide and you used that latitude correctly. The visibility reporter's copy is written for a gate that did not bite; this one does, and the new prefix and gate kind exist so the two lines can say true things about opposite polarities without a second reporter to maintain.

One test worth calling out because it answers a question nobody asked: "the same fault produces the same bytes in development and in production." The card was explicitly about a diagnostic missing in both builds, so pinning byte-equality across them closes the card's actual scope rather than half of it.

Landing:cbb77c304 reads FAILED=none, PEND=NONE across all 29 check runs, read by name — the exact head reviewed here. Queuing now.


Generated by Claude Code

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.

The disabled / disabledOn node gate has NO fault diagnostic in either build — and its fail-soft polarity greys a control out rather than showing it

2 participants

@os-support-ai@claude