Skip to content

fix(react): a faulting node-gate predicate is loud in production, once per predicate source - #6446

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6038-visiblewhen-fault-warning
Aug 26, 2026
Merged

fix(react): a faulting node-gate predicate is loud in production, once per predicate source#6446
os-support-ai merged 2 commits into
mainfrom
claude/issue-6038-visiblewhen-fault-warning

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6038

Maintainer ruling, 2026-08-25, in-session batch adjudication (verbatim: 「就全部接受,然后继续下一批」) ⇒ Option B: production bundles emit a rate-limited, deduplicated warning (one per distinct failing predicate) when a visibleWhen / node-gate predicate faults, replacing the current __DEV__-only silence. Observability only — the fail-open semantics are not this card's to change.

The premise, verified — and it holds for one dialect of three

The card says a faulting node-gate predicate prints nothing in production. Measured on the built evaluator (packages/core/dist/evaluator/ExpressionEvaluator.js) at the branch point, that is true of one dialect, and the other two fail in opposite directions:

dialectproduction console, beforeafter
bare stringnothing1 node-bearing line per distinct source
{ dialect: 'cel' } envelope1 generic line, deduped per source (evalFieldPredicate)1 node-bearing line — the generic one is replaced, not joined
${…} template1 generic line per evaluation, never deduped1 node-bearing line per distinct source

So the silent dialect is the bare string — exactly the one objectstack#11254 measured a live gate breaking on — while the template dialect was already the console flood the ruling's rate-limit clause exists to prevent. The card's premise stands; this table is what it looks like once measured, and it is why several tests here pin the totalconsole.warn count rather than just the count of matching lines.

The design constraint, and the way out of it

throwOnError was the only fault-detection channel a fail-soft caller had, and on the CEL branch evaluateCelCondition implements it by evaluating twice (once with each fallback). Spec-parsed metadata normalises visibleWhen into a { dialect: 'cel' } envelope, so that is the common production branch — paying for the probe would double the engine calls for every predicate of every node of every render. That trade is the whole reason the diagnostic shipped behind __DEV__.

EvaluationOptions.onFault (new, @object-ui/core) removes the trade: every fault site is already inside a catch, or already holds the canonical engine's failure reason, so the fault is reported at the same number of engine calls. It mirrors one layer up the seam FieldPredicateDiagnostic already documents (warn: false plus a reason passback), and on the CEL branch it is forwarded to that seam rather than reimplemented. Supplying it transfers reporting to the caller, so one fault stays one line — the caller's, which can name the node.

Pinned rather than asserted: with a counting getter on the record, the CEL branch performs the same number of reads with the passback as a plain fail-soft call, and the throwOnError probe performs strictly more.

SchemaRenderer's production branch passes it and reports through the same reporter the dev branch uses — same message, same severity, same dedupe Set, same key. Dev and production now print the identical line for the identical fault (pinned by string equality across the two module graphs). The __DEV__ gate no longer decides whether a fault is reported, only how it is detected.

Census — every predicate-fault catch site, and what happened to it

Censused by shape (a predicate evaluation caught and swallowed), not by symbol.

sitebeforethis PR
SchemaRenderer.evaluateVisibilityPredicatevisibleWhen / visible / visibleOn / visibility / hidden / hiddenOn, plus #5756's pre-hoist properties calldev-only report; production silent (bare string)fixed — the chartered site
PageTabsRenderer.isItemVisible (page:tabs item visibleWhen, @object-ui/components)silent in both buildsfixed — see below
ExpressionProvider.evaluateVisibility (nav/area item visible, @object-ui/app-shell)silent in both buildsfiled as #6443
ExpressionEvaluator.evaluate's two generic warns (props / content / properties)one line per evaluation, never dedupedfiled as #6444 (deduped here only where onFault is passed)
SchemaRenderer's disabled / disabledOn gateno diagnostic in either buildfiled as #6445
useCondition fail-closed legalready warns in production, dedupedunchanged
field-rule / row-predicate tiers (evalFieldPredicate)already warn in production, dedupedunchanged

Why page:tabs is fixed here and the other three are not. It is the same canonical key (visibleWhen) with the same declared contract — its own comment says "the same semantics SchemaRenderer applies to component-level visibleWhen" — and it was the worse of the two: the node gate at least reported in development, while a faulting item predicate was silent in both builds on a gate whose false verdict removes an entire tab, header and panel. Covering it is five lines and one import that already existed. The other three are a different key, a different tier, or a non-gate path; each is stated in its own issue rather than quietly widened into this PR's file surface.

Declared file-surface amendment (the claim comment named packages/react only): this PR also touches packages/core/src/evaluator/ExpressionEvaluator.ts and packages/components/src/renderers/layout/containers.tsx.

Rate limit — both halves, because either alone is untestable

Deduped per (node type, gate key, predicate source) — never per render, never per node instance. Proven, not asserted:

  • three nodes with different ids and the same source → 1 line (not the call-site instance);
  • three re-renders of the same predicate → 1 line (not the render);
  • a 200-row list of one broken predicate → 1 line, and 1 total console.warn;
  • a second distinct predicate source → its own line.

The last one is not optional: "a warning was emitted" is green on an implementation that emitted fifty, and "exactly one was emitted" is green on one that suppresses everything after the first line ever printed.

Controls

Every assertion reads a prefix-filtered view of captured console.warn output, plus, in both new test files:

  • a positive control — the spy observes a line the test emits itself (without it, every toHaveLength(0) is equally green on a broken capture);
  • a degenerate control — unrelated console output ([object-ui] …, a React key warning) does not satisfy the pin.

And the silences that make the noise mean something: a healthy predicate on either verdict, a node with no gate, and #5687's adapter-only data.* report — which stays development-only under its own 2026-08-22 ruling and is pinned silent in production, so "production is louder" cannot drift into "production reports everything dev does".

Reverse verification — direction predicted before each run, restored by hash

Two isolated legs, each restoring one file to origin/main and running its own pins. No rebuild leg: vitest resolves every workspace specifier to <pkg>/src via the root vitest.config.mts alias table, so dist/ takes no part in any reading. Each leg verified the mutation landed (hash-object == the origin/main blob, marker count → 0) before measuring, and restored with git checkout HEAD -- <abs path>, verified by hash equality with the HEAD blob and an empty git diff HEAD.

legpredictedmeasured
A — packages/react/src/SchemaRenderer.tsxorigin/main9 failed / 5 passed; group 0 controls and group 3 silences stay green9 failed / 5 passed, failing case names exactly the predicted set
B — packages/components/.../containers.tsxorigin/main4 failed / 3 passed; both controls and the healthy case stay green4 failed / 3 passed

Both readings on the acceptance criterion, quoted: after — reports(warn) has length 1; before — AssertionError: expected [] to have a length of 1 but got +0.

The asymmetry is the card restated: every verdict assertion stayed green in both legs. The change moves the silence, not the answer.

Bundle cost — measured A/B, not argued

The reporter module was already in every production bundle: it is imported at SchemaRenderer's module top level and __DEV__ is a runtime IIFE reading globalThis.process?.env?.NODE_ENV (not the process.env.NODE_ENV member expression bundlers constant-fold), so it was never tree-shaken. Only the wiring is new.

Measured by building apps/console twice with identical tooling — once in a detached compare worktree pinned to this branch's base commit9602dc820 (not moving origin/main), once here — then running the repo's own Bundle Analysis gate on each:

readingbase 9602dc820this branchdelta
aggregate eager closure (gate's number)3233.6 KB gz3233.7 KB gz+0.1 KB (headroom 33.0 → 32.9 KB)
chunk framework (SchemaRenderer + evaluator)497,552 B gz497,623 B gz+71 B
chunk ui-components (containers.tsx)387,824 B gz387,860 B gz+36 B
chunk vendor-objectstack945,569 B gz945,569 B gz0

+107 bytes gzipped, across the two chunks that carry the change. Every per-chunk ceiling unmoved at the gate's 0.1 KB resolution; the checker exits ✅ on both trees.

An existing pin was rewritten, deliberately

SchemaRenderer.visibleWhenRecordBinding.test.tsx asserted that a production build printed none of this diagnostic — the exact property the ruling retires ("A is rejected — the silence is no longer an accepted property"). It now asserts two lines, one per distinct faulting source, with every verdict it was really guarding unchanged and still asserted. Two neighbouring comments that described #5687's dev-only gate as shared by both legs are corrected: they are no longer shared, and that asymmetry is deliberate — a fault is a predicate that could not be evaluated, while that leg reports one that evaluated perfectly against the wrong object, on a lexical scan with a stated false-positive residue.

Verification

Union run at 3893507fc, after the final commit.

  • pnpm exec vitest run packages/core/ packages/react/158 files, 2866 tests, all passed
  • pnpm exec vitest run packages/components/194 files, 1754 tests, all passed
  • the five files this PR adds or rewrites → 5 files, 72 tests passed (run at 3893507fc)
  • pnpm exec turbo run type-check --filter=@object-ui/core --filter=@object-ui/react --filter=@object-ui/components11 successful, 11 total, each package echoing tsc --noEmit && tsc -p tsconfig.test.json. All three new test files confirmed inside the typecheck file set via --listFiles (1 hit each), so "typecheck is clean" actually covers them.
  • pnpm lint (full repo, not narrowed) → 47/47 tasks successful, 0 cached, 0 errors repo-wide (pre-existing no-explicit-any warnings only)
  • pnpm check:control-bytes → ✅ 5317 tracked text files; plus a direct grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the diff — clean
  • pnpm check:readme-exports → ✅ 378/378 self-imports real, 0 fabricated · pnpm check:doc-snippets → ✅ 267/267 blocks · check:doc-fences, check:phantom-deps, check:self-import, check:esm-specifiers → ✅
  • node scripts/check-changeset-presence.mjs → ✅ 5 source files of 3 released packages, 1 changeset

Both check:readme-exports and check:doc-snippets first exited non-zero with run the build first — a prerequisite, not a verdict. Re-run after turbo run build --filter='./packages/*' (39/39 successful); the readings above are the post-build ones.

Out of scope, by the ruling

⛔ Draft on purpose: the PM lands this. Not marked ready, not self-merged, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

… per predicate source
A `visibleWhen` (and `visible` / `visibleOn` / `visibility` / `hidden` /
`hiddenOn`) predicate that cannot be evaluated resolves to the same answer as
one that said yes, so a gate that stops biting looks exactly like a gate the
author got right. The diagnostic that names it sat behind a `__DEV__`
short-circuit, because the only fault-detection channel was `throwOnError`,
which the CEL branch implements by evaluating twice.
Measured on the built evaluator, the silence was dialect-dependent: a bare
string printed nothing, a `{ dialect: 'cel' }` envelope printed one generic
deduped line, and a `${…}` template printed one generic line PER EVALUATION.
The dialect a live gate was measured breaking on was the silent one.
`EvaluationOptions.onFault` reports the fault the evaluator has already
detected — every fault site is already inside a `catch`, or already holds the
canonical engine's reason — so nothing is evaluated twice, and supplying it
transfers reporting to the caller so one fault stays one line. `SchemaRenderer`
passes it in production and reports through the same reporter, message, dedupe
`Set` and key the dev branch uses; `page:tabs` item predicates join the same
rate limit.
Observability only: no verdict moves, on any path or dialect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
…operty
`SchemaRenderer.visibleWhenRecordBinding` asserted that a production build
printed none of this diagnostic. That is precisely the property the maintainer's
2026-08-25 ruling retired ("A is rejected — the silence is no longer an accepted
property"), so the pin is rewritten rather than deleted: the two faulting
predicates it mounts now assert one line each, and every verdict it was really
guarding is unchanged and still asserted.
Also corrects two neighbouring comments that described the #5687 adapter-only
leg's dev-only gate as shared by both legs. It is not shared any more, and the
asymmetry is deliberate: a fault is a predicate that could not be evaluated,
while that leg reports a predicate that evaluated perfectly against the wrong
object, on a lexical scan with a stated false-positive residue.
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)3233.7 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-gjhOeBGM.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)505.99KB114.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.66KB12.84KB
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.10KB62.31KB
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.79KB54.60KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.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)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
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

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

Projects

None yet

2 participants

@os-support-ai@claude