Skip to content

fix(react): make a node-gate data.* predicate the adapter cannot answer loud (#5687) - #5757

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5687-node-gate-data-predicate-report
Aug 23, 2026
Merged

fix(react): make a node-gate data.* predicate the adapter cannot answer loud (#5687)#5757
os-zhuang merged 1 commit into
mainfrom
claude/issue-5687-node-gate-data-predicate-report

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5687

Maintainer ruling, 2026-08-22, option A: the node tier keeps its documented data = adapter semantics; the deprecated spelling's constant-false gets loud. No verdict changes, no interpolation changes — #5330's row binding does not extend here, and the docblock's "silent interpolation change nobody asked for" stays refused. What lands instead is the #5454 reporter posture, extended to this non-throwing path.

The defect, re-measured on this base (not inherited from the card)

The card measured on 3b147a367; this branch is off 3ece13e33. All three of its load-bearing facts were re-checked, and one of them has changed:

card's factstatus on 3ece13e33
SchemaRenderer hoists every properties.* value onto the node, so properties.visible arrives as schema.visibleholds — the hoist block is intact
the node-visibility evaluator is built with data: dataSource, the adapter, defaulting to NO_DATA_SOURCE = {}holdsdata: dataSource is still written last, after the ambient spread and after record
the #5454 reporter keys off a throw, which this path never doesholds only for two of the three dialects — see below

Measured directly against @object-ui/core's built evaluator with data: {}:

bare string "data.status == 'draft'" -> false, NO throw (silent)
${…} template "${data.status == 'draft'}" -> false, NO throw (silent)
CEL envelope { dialect:'cel', source:"data.status == …" } -> THROWS under throwOnError

So the CEL envelope already reports today, through reportUnresolvableVisibilityPredicate. The bare-string and template dialects do not. That is precisely the arbitrariness #5454 existed to remove — "whether an author heard about their own typo depended on which dialect they happened to write it in" — surviving one path further along. The card's repro shape is the bare string, so the gap is real and this is its narrowest closure.

What changed

Two files, one call, ~200 lines of which most are the reasoning.

  • packages/react/src/utils/visibilityDiagnostic.tsreportAdapterOnlyDataPredicate() beside the existing reporter. Same module, same dedupe Set, same __resetVisibilityPredicateWarnings(), same console.warn severity, same dev-only gate at the same call site. The dedupe key is tagged with this leg's name so the two diagnostics cannot silence each other for one (type, key, source) triple — they are different faults.
  • packages/react/src/SchemaRenderer.tsx — one call, on the non-throwing branch only:
constverdict=evaluator.evaluateCondition(raw,{throwOnError: true});reportAdapterOnlyDataPredicate(newSchema.type,newSchema.id,key,raw,dataSource);returnverdict;

verdict is returned exactly as computed. "No verdict changes" is true by construction here, not by review.

The trigger, and why it is this one

The ruling's trigger is not references data. — a genuine ${data.total} adapter read must stay silent, and that half is what makes the loud half mean anything. Four candidate definitions were measured, not argued:

definitionon the reproon data.total > 0 (adapter has total)verdict
references data. at allfiresfiresrefused by the ruling in as many words
the whole predicate is constant-falsefires— but data.total > 100 against {total:99}fires, and so does a correct record.status == 'draft' against a non-draft rowcannot tell a gate that correctly says NO from one that never asked; would report every hiding gate in the repo
the comparison operand is undefinedfiressilentmost precise, but needs @object-ui/core's evaluator to expose its operands — an engine change for a diagnostic on a card that dissolves
a data.* read the bound object does not answerfiressilentshipped

The last one is also structurally incapable of firing on the record.* bucket (#5401#5454): a predicate with no data. root has nothing for it to look up. The two adjacent cards cannot bleed into each other through this code.

Residue, stated rather than hidden: deliberate absence idioms (data.status == null, !data.status) are reported. They are not really false positives at this tier — an adapter with no status makes those constants too, just constant-true — but they are shapes an author could have meant.

The scan is lexical, so string literals are stripped before it runs (record.status == 'data.status' reads nothing and says nothing). Not hypothetical: examples/schema-catalog/src/schemas/components-complex-scroll-area/code-preview.json carries data.features inside a JS snippet in a content string.

The pin is a triple

packages/react/src/__tests__/SchemaRenderer.nodeGateDataPredicate.test.tsx, 18 cases. Drop any leg and the file goes green on an implementation that reports everything, or nothing.

  1. the repro reportsproperties: { visible: "data.status == 'draft'" } warns, names the node/key/predicate and the unanswered read, and still hides, on both rows;
  2. canonical record.* stays silent — opposite verdicts on the two row polarities, zero diagnostics;
  3. a genuine adapter read stays silent${data.total} still interpolates to 99 in a props bag, and data.total > 0 as a visibility gate is silent on both polarities.

The case that decides which discriminator is implemented is 2c: same predicate text, same false verdict, adapter now has the key → silent. It holds the source and the verdict fixed and moves only the adapter.

Also pinned: the non-negated hidden leg keeps its inverted answer; metadata.* and record.data.* are not data.*; a CEL envelope reading data.* is reported once, by #5454, never twice; production takes the single-evaluation branch, reaches the same four verdicts and prints nothing.

Ablation — direction predicted before running, both legs restored under trap … EXIT INT TERM

Both mutations were confirmed on disk by anchored counts in both directions (the deleted text at 0, an injected marker at 1) before any test was read. The mutated subject is imported by the tests via a relative source path (../SchemaRenderer), not through a package exportsdist, so no rebuild is involved and no stale artifact can make a mutation read as green.

A — delete the reporter call. Predicted: red exactly the positive cases, green every silence case and every verdict assertion. Observed: 6 red, 32 green, and the entire #5454 pin file green.

B — delete the unresolved.length === 0 silence guard (i.e. report on any data. mention). Predicted: red the silence cases that actually reach the reporter. Observed: 5 red — 2b, 2c, 2d, 3c, 3d — group 1 and production untouched.

The two brackets are what make the discriminator checkable: A kills a "report nothing" implementation, B kills a "report everything" one.

Ablation B found a bad control, which is the point of running it. Case 3c (metadata.* is not data.*) survived B on the first run. Measured cause: with metadata unbound the predicate throws (metadata is not defined), lands in the #5454 catch, and never reaches this leg at all — so it was green for a reason unrelated to the boundary it claims to check. metadata is now bound in that case's scope; it takes the non-throwing path, and it dies under B as it should.

Controls that correctly survive both ablations, and why: 3a/3b (record.*) — neither mutation touches a path without a data. root, which is exactly the evidence the two cards don't bleed; 3e (CEL envelope) — it throws first, so it is #5454's case in both worlds; group 4 (production) — __DEV__ short-circuits ahead of both mutations.

Verification

Union run on 06561bde3, the final commit, after the rebase.

  • pnpm exec vitest run packages/react/52 files, 706 tests, 0 failures. pnpm exec vitest list packages/react/ collects 706 cases, 18 of them this file's — the default reporter names no passing file, so inclusion is read from vitest's own collection rather than assumed.
  • pnpm exec vitest run packages/react/ packages/components/ packages/plugin-grid/ packages/types/363 files, 3635 tests, 0 failures.
  • pnpm --filter @object-ui/react type-check → exit 0. pnpm --filter @object-ui/react lint✖ 347 problems (0 errors, 347 warnings); all 347 are pre-existing no-explicit-any, and none sits in either of this diff's two hunks in SchemaRenderer.tsx (warning lines vs. hunk ranges compared).
  • check-control-bytes✅ OK (scanned 4801 tracked text file(s)) · check-changeset-presence✅ … declares 1 changeset(s) · changeset-fixed · changeset-no-major · lint-coverage✅ 46/46 packages linted, 0 with outstanding errors · type-check-coverage · phantom-deps · self-import · esm-specifiers · spec-symbols · action-forward-parity — all exit 0, quoting each gate's own verdict line.
  • check-eager-closure fails with its own "No eager-closure report … the console was not built … This is a broken gauge, not a passing budget" — the known worktree gauge. It cannot move here regardless: the module specifiers imported by SchemaRenderer.tsx are byte-identical before and after (10 before, 10 after, empty diff) — the change widens an existing named import rather than adding an edge.

Declared narrowing:packages/app-shell/ and apps/console/ exceed the 10-minute foreground cap as whole suites, so the run was narrowed to the actual blast surface — every file carrying a data.* visibility predicate — packages/app-shell/src/views/metadata-admin/ + apps/console/src/components/FormPage.fieldSpec.test.ts195 files, 1979 passed, 1 skipped. The residual risk a new console.warn poses is a test asserting exact console call counts; the vitest config declares no onConsoleLog / failOnConsole (grep exit 1, controlled against a term known to be in the file), so an unexpected warn cannot fail a suite by configuration. CI runs the full farm regardless.

Deprecated-spelling inventory (#5687's open question, answered)

The triage analysis flagged what it could not see: whether node-tier data.* predicates exist in real metadata. Controlled sweep of examples/, apps/, content/, packages/:

  • node-tier data.* visibility predicates in non-test source: zero.
  • The only two real data.* visibility predicates in the repo — packages/app-shell/src/views/metadata-admin/anchors.ts — are at the SchemaForm field tier, where evaluatePredicate(visibility, { data: value }) binds data to the form draft values. A correct, different binding on a different evaluator, which this change cannot reach.

Controls for those zeros, since a zero hit is not a reading: the same shape spelled record.* → 246 hits; the visibility keys at all → 1273 hits; data. anywhere → 744 hits; and the sweep demonstrably enters examples/schema-catalog (5 visibility-key hits there, all record.* / current_user.*). Nobody is standing in the blast radius — useful for whoever owns #5330's deprecation window.

Filed, not fixed here

#5756 — a ${…} predicate written insideproperties is interpolated to a boolean before the hoist, so it reaches the chain as a bare false and no gate-side diagnostic can ever see it (this one or #5454's). It hides the block on every row, silently. Measured, and pinned in this PR as an explicit known limit rather than papered over. Closing it means adding a diagnostic inside the interpolation loop — the surface this ruling fences off — and it carries two real choices, so it belongs to triage, not to this card.

One deliberate reading of the ruling, flagged for review

The ruling says this path "emits the dev-only unresolvable-predicate report". I read that as the reporter — its module, severity, dedupe and lifecycle, all of which are shared verbatim — and gave this leg its own first line (ADAPTER_ONLY_DATA_PREDICATE_PREFIX) rather than reusing 'A visibility predicate could not be evaluated'. On this path that sentence would be false: the predicate evaluated perfectly, against the wrong object, and produced a constant. Telling an author "could not be evaluated" sends them hunting for a syntax error that is not there. Dispatch guidance was that posture matters more than message text, so this is where the flexibility was spent — and if the literal string was meant, it is a one-line change.

Temporary by design

This is loudness, not a feature. It dissolves when #5330's deprecation window for the data.* row spelling closes; the changeset says so.


Generated by Claude Code

…swer loud (#5687)
At the node visibility tier `data` is the data-source ADAPTER — what
`${data.total}` in a props bag resolves against — and it has never been the
row. A predicate written with the deprecated `data.*` spelling therefore
resolves `undefined == 'draft'`, which is a clean `false`: the block is hidden
on every row, and because nothing throws, the unresolvable-predicate reporter
added for #5454 never fired.
Measured on this base, the same predicate written as a `{ dialect: 'cel' }`
envelope DOES throw and IS already reported. So the card's premise ("this path
never throws") holds for the bare-string and `${…}` template dialects only —
whether an author heard about the identical mistake depended on which dialect
they wrote it in, which is the arbitrariness #5454 existed to remove.
Per the maintainer ruling of 2026-08-22 (option A): no verdict changes and no
interpolation changes. The evaluator's answer is returned exactly as computed;
this adds a dev-only `console.warn` on the non-throwing branch, through the
same module, dedupe Set and lifecycle as the #5454 reporter.
The trigger is a `data.*` read the bound adapter answers with `undefined`, not
the spelling: a genuine adapter read, a canonical `record.*` predicate and a
correctly-hiding gate all stay silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3917.7 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-m2frlNnO.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.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)16.66KB6.35KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)33.99KB8.57KB
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)510.39KB114.67KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
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.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.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)3.77KB1.33KB
react (SchemaRenderer.js)44.39KB14.99KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
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)6.92KB2.40KB
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)0.20KB0.18KB
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 (index.js)3.59KB1.79KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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-zhuang@claude