Skip to content

fix(types): BaseSchema.visible accepts the predicate string the renderer evaluates (#4581) - #4593

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4580-one-schemanode
Aug 13, 2026
Merged

fix(types): BaseSchema.visible accepts the predicate string the renderer evaluates (#4581)#4593
yinlianghui merged 1 commit into
mainfrom
claude/issue-4580-one-schemanode

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4581.

⚠️ This PR is deliberately smaller than the card it was dispatched for. The seat was dispatched to reconcile SchemaNode (#4580) and to ride #4581 along with it. Measurement blocked both of those on one unruled contract question, so this PR ships only the half that measured clean, and the rest is escalated rather than guessed. #4580 is not touched here and stays open; #4581 is half done, hence Part of, not Fixes.

What landed

BaseSchema.visible is declared boolean | string.

It was boolean, but the renderer never read it as a boolean — it evaluates the key. Both call sites the ruling asked me to verify, cited:

  • visiblepackages/react/src/SchemaRenderer.tsx:382:

    if (newSchema.visible !== undefined) {
      return !evaluator.evaluateCondition(newSchema.visible);
    }

    and evaluateCondition is declared (condition: string | boolean | undefined, context?) => boolean (packages/core/dist/evaluator/ExpressionEvaluator.d.ts:143). The sibling keys visibleWhen and the deprecated visibleOn are string for exactly this reason. The widening is boolean | string — what the evaluator accepts, no wider.

  • ariaLabelpackages/react/src/SchemaRenderer.tsx:111 calls resolveKeyedI18nLabel(schema.ariaLabel). This call site is what disproved the ruling's spelling for the ariaLabel half. See "Why ariaLabel did not land" below.

Two as unknown as BaseSchema casts in SchemaRenderer.expressions.test.tsx existed only for this gap and are dropped.

The census — the recorded "five" is not what is on disk

The ruling said five casts, "each carrying a comment naming this gap". Grepping as BaseSchema / as unknown as BaseSchema across packages and apps finds six, in three different classes, and only one carries such a comment:

Site Value Class
react/__tests__/SchemaRenderer.expressions.test.tsx:52 visible: '${data.role === "admin"}' visible gap — dropped here
react/__tests__/SchemaRenderer.expressions.test.tsx:61 visible: '${data.role === "admin"}' visible gap — dropped here
react/__tests__/SchemaRenderer.expressions.test.tsx:132 disabled: '${data.status === "locked"}' disabled gap — not ruled, left
react/__tests__/SchemaRenderer.expressions.test.tsx:141 disabled: '${data.status === "locked"}' disabled gap — not ruled, left
react/__tests__/SchemaRenderer.aria.test.tsx:59 ariaLabel: { key: …, defaultValue: … } ariaLabel gap — blocked, left
components/__tests__/html-anchor-links.test.tsx:33 { type: 'a', ...schema } unrelated — a Record< string, unknown > spread, no comment

So of the six, this card's ruled widenings close two. Three further notes:

  1. disabled is the same defect as visible, with the same evidenceSchemaRenderer.tsx:466 evaluates it through the same evaluateCondition, and a disabledOn?: string sibling exists for the same reason. finding(types): BaseSchema under-declares visible (predicate string) and ariaLabel (I18nLabel) — the renderer supports both, five test fixtures now cast past the gap #4581 named only visible and ariaLabel, so I did not widen it unruled. Two casts and one one-line widening are waiting on a word.
  2. html-anchor-links.test.tsx is the file finding(types): BaseSchema under-declares visible (predicate string) and ariaLabel (I18nLabel) — the renderer supports both, five test fixtures now cast past the gap #4581 named, but the cast in it is not this gap — it is a Record< string, unknown > spread being cast to BaseSchema.
  3. plugin-dashboard's two as BaseSchema are finding(react): SchemaRenderer carries the #4422 prop erasure in a spelling every sweep and both guards are blind to — Record< string, any > instead of [key: string]: any #4548's deliberate narrowing casts, untouched.

Red-first — predictions written into the test header before the run

packages/types/src/__tests__/base-schema-visible-predicate.test.ts pins the widening. Against origin/main (92250d648), tsc -p packages/types/tsconfig.test.json reported, verbatim:

src/__tests__/base-schema-visible-predicate.test.ts(67,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/base-schema-visible-predicate.test.ts(75,3): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.
src/__tests__/base-schema-visible-predicate.test.ts(81,3): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.

Post-fix: clean, exit 0.

The equality assertion is invariant (Equal, not extends) on purpose, and the header says why: a satisfies-style or one-way extends check would be vacuous in both directions here — the narrow boolean is assignable to the wide boolean | string, so a widening that never happened and a widening that overshot to any would both stay green. BaseSchema's [key: string]: any index signature makes the overshoot a live risk, not a hypothetical: deleting the declared property altogether leaves visible typed any and every fixture still compiling. Pinning the exact union is the only assertion that can go red for the right reason.

must-not-change

  • Emitted JS is byte-identical. All 54 .js files in @object-ui/types' dist have equal sha256 against an origin/main compare worktree, built the same way. This is the finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528 bundle-sha256 bar, and a type-only card meets it exactly — ruling 4's "zero runtime behavior change" is satisfied at the strongest available standard rather than argued.
  • packages/types + packages/core suites: 119 files, 2200 tests, all passing.
  • Repo-wide type-check canary, PREFIX filter (--filter='...@object-ui/types' = the downstream consumers, 27 packages): zero new errors attributable to this widening. Measured in isolation, with the SchemaNode change reverted, precisely so the two changes could not launder each other.
  • eslint on the touched files vs the origin/main compare worktree: 21 warnings / 0 errors both sides — net zero; the new test file contributes 0.
  • All nine local gate scripts pass; control-byte and NBSP self-scan clean over every touched file including untracked.

Published .d.ts diff, and the grading analysis

Built both ways with dist/ and *.tsbuildinfo cleared between builds. The entire diff:

      /**
       * Controls whether the component is visible.
       * When false, component is not rendered (display: none).
+      *  … (evidence comment)
       * @default true
       */
-     visible?: boolean;
+     visible?: boolean | string;

Graded minor by position analysis, not by assumption: the diff adds a member to a union on an authored-input-dominant property, removes nothing, and touches no other declaration — the #4586/#4591 shape. Authors gain a spelling; nothing that type-checked before stops doing so. Readers of schema.visible were already coping with any through the index signature. Never major (major tracks @objectstack).

@object-ui/core gets no changeset entry: its declaration is untouched in this PR, so it has no source or declaration diff.

toRenderableSchema in packages/react STAYS

Stated explicitly so no future card "cleans it up": SchemaRenderer's component-level union deliberately excludes number / boolean (#4548 ruling, Q2), so the bridge still normalizes those onto their text form. It is a total function, not a cast. Nothing in this PR makes it an identity function, and nothing here is a reason to remove it.

Why SchemaNode (#4580) did not land — the measured blocker

The reconciliation itself works. Implemented as ruled (core's interface SchemaNode becomes export type { SchemaNode } from '@object-ui/types';), built clean, and the red-first collision pin went red pre-fix exactly as predicted and clean post-fix — the #4548 error class, verbatim, naming both dist identities:

SchemaNode.reconciliation.test.ts(64,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
SchemaNode.reconciliation.test.ts(77,14): error TS2322: Type 'import(".../packages/types/dist/base").SchemaNode' is not assignable to type 'import(".../packages/core/dist/types/index").SchemaNode'.
SchemaNode.reconciliation.test.ts(81,19): error TS2345: Argument of type 'import(".../packages/types/dist/base").SchemaNode' is not assignable to parameter of type 'import(".../packages/core/dist/types/index").SchemaNode'.

core/dist/index.d.ts was unchanged by it — the entry surface is preserved, as the ruling required.

What stopped it is the repo-wide canary. The reconciliation surfaces 274 errors in @object-ui/react:

  • 272 in test files (5 spec-bridge suites) — TS18049 ×134 and TS2339 ×131, all of the form Property 'sections' does not exist on type 'string | number | boolean | BaseSchema'. Mechanical narrowing, in scope, mine — but 272 of them is a cost worth a decision on its own, and it points at the bridges' declared return type as the real lever rather than 272 local narrowings.
  • 2 in runtime source, and these are the blocker — packages/react/src/spec-bridge/bridges/list-view.ts:180 and :224:
error TS2322: Type 'string | Record< string, string >' is not assignable to type 'string | undefined'.

from if (spec.label) node.label = spec.label; and the description twin. spec.label is the spec's I18nLabel; BaseSchema.label is string. Core's [key: string]: any had been absorbing that mismatch — remove the duplicate declaration and a real latent defect surfaces. Fixing it needs a cast (the lenient-consumer fallback the contract-first rule forbids) or a producer-side widening that nobody has ruled. Per ruling 4, runtime-source reshaping is a STOP, so I stopped.

Why ariaLabel did not land — the ruling's spelling is the wrong vocabulary

The ruling says widen ariaLabel to string | I18nLabel. Measured, that spelling declares a shape the renderer cannot resolve, and still rejects the shape it can. @object-ui/types re-exports I18nLabel from @objectstack/spec/ui, where it is the inline locale map string | Record< string, string >. But SchemaRenderer.tsx:111 resolves ariaLabel with resolveKeyedI18nLabel, whose declared input is the keyed form string | { key: string; defaultValue?: string; params?: Record< string, any > }. packages/react/src/utils/i18n.ts documents these as two vocabularies that "answer wrongly for the other's input, silently" (objectui#4167, PR #4169).

A throwaway probe measured all four consequences, and all four predictions held:

Probe Result
I18nLabel is string | Record< string, string > confirmed
the shipped fixture { key, defaultValue } under string | I18nLabel accepted — for the wrong reason, as a locale map whose "locales" are named key and defaultValue
the same keyed label carrying params: { name: 'Ada' } rejected: Type '{ name: string; }' is not assignable to type 'string'
the genuine inline map { en: 'Owner' } accepted by the type, and resolveKeyedI18nLabel returns undefined for it at runtime, rendering an empty aria-label

So the ruled widening would have turned the aria test green vacuously, invited a shape that renders empty, and still rejected keyed labels with params. That is the opposite of the ruling's own stated intent ("matching what the renderer actually supports"), so I did not write it.

The two escalations are one question

BaseSchema declares three sibling label slots — label?: string (:56), description?: string (:62), ariaLabel?: string (:173) — and all three under-declare, in two different i18n vocabularies: label/description receive the spec's inline I18nLabel from the bridges, while ariaLabel is resolved with the keyed resolver. Core's index signature was hiding all of it.

That is why #4580 is blocked on #4581 rather than the other way round: the ruling treated the ariaLabel widening as a rider on the reconciliation, but the reconciliation is what forces the label-vocabulary decision. One ruling on "which vocabulary does each BaseSchema label slot declare" unblocks the reconciliation, the ariaLabel widening, and the two list-view.ts defects together. Options and a recommendation are in the seat's report.


Generated by Claude Code

…rer evaluates (#4581)

`visible` was declared `boolean`, but the renderer never read it as one — it
evaluates the key. `SchemaRenderer.tsx:382` calls
`evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is
declared `(condition: string | boolean | undefined, context?) => boolean`. The
sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that
same reason; `visible` under-reported a capability it already had, and fixtures
exercising it had to cast past the declaration.

Widened to `boolean | string` — exactly what the evaluator accepts, no wider —
and the two `as unknown as BaseSchema` casts in the expressions suite that
existed only for this gap are dropped.

Type-only: all 54 emitted `.js` files in @object-ui/types are byte-identical to
origin/main.

Part of #4581. #4580 and the ariaLabel half are escalated rather than
implemented — see the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 4:54pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-B54PSc01.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.33KB 108.47KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Sign up for free to 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.

2 participants