Skip to content

Commit 3d9769a

Browse files
yinlianghuiclaude
andauthored
fix(types): BaseSchema.visible accepts the predicate string the renderer evaluates (#4581) (#4593)
`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. Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92250d6 commit 3d9769a

4 files changed

Lines changed: 147 additions & 8 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
`BaseSchema.visible` accepts the predicate string the renderer evaluates
6+
7+
`visible` was declared `boolean`, but the renderer never read it as one: it
8+
evaluates the key — `SchemaRenderer.tsx:382` calls
9+
`evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is
10+
declared `(condition: string | boolean | undefined, context?) => boolean`. The
11+
sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that
12+
same reason; `visible` simply under-reported a capability it already had, and
13+
fixtures exercising it had to cast past the declaration.
14+
15+
Now `boolean | string` — exactly what the evaluator accepts, no wider.
16+
17+
Graded **minor** by position analysis of the published `.d.ts`: the only diff is
18+
`visible?: boolean` becoming `visible?: boolean | string` on an
19+
authored-input-dominant property, with no union member removed and no other
20+
declaration touched — the same shape as #4586/#4591. Authors gain a spelling;
21+
nothing that previously type-checked stops doing so. Code that READS
22+
`schema.visible` was already coping with `any` through `BaseSchema`'s index
23+
signature.

packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import { render, screen } from '@testing-library/react';
1111
import React from 'react';
1212
import { ComponentRegistry } from '@object-ui/core';
1313
import { SchemaRenderer } from '../SchemaRenderer';
14-
// `@object-ui/types` declares `BaseSchema.visible` / `.disabled` as `boolean`,
15-
// but BOTH accept a predicate STRING here — that is the capability these cases
16-
// exercise, and the renderer evaluates it (`evaluateCondition`). The declaration
17-
// is the narrow one; until it is widened these fixtures state their real shape
18-
// through `BaseSchema`'s index signature (objectui#4548 measured the gap).
14+
// `BaseSchema.visible` now declares `boolean | string` (objectui#4581), so the
15+
// two visibility cases below state their predicate strings directly — no cast.
16+
//
17+
// `.disabled` is the SAME gap and is NOT yet widened: the renderer evaluates it
18+
// through the same `evaluateCondition` (`SchemaRenderer.tsx:466`) and the
19+
// `disabledOn?: string` sibling exists for the same reason, but #4581 named only
20+
// `visible` and `ariaLabel`, so widening `disabled` was left to its own card
21+
// rather than taken unruled. The two `disabled` casts below are what remains of
22+
// the gap — drop them when that lands.
1923
import type { BaseSchema } from '@object-ui/types';
2024
import { SchemaRendererContext } from '../context/SchemaRendererContext';
2125

@@ -49,7 +53,7 @@ describe('SchemaRenderer Expression Integration', () => {
4953
it('evaluates visible expression string', () => {
5054
render(
5155
<SchemaRendererContext.Provider value={{ dataSource: { role: 'admin' } }}>
52-
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' } as unknown as BaseSchema} />
56+
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' }} />
5357
</SchemaRendererContext.Provider>
5458
);
5559
expect(screen.getByTestId('test-component')).toBeInTheDocument();
@@ -58,7 +62,7 @@ describe('SchemaRenderer Expression Integration', () => {
5862
it('hides when visible expression evaluates to false', () => {
5963
const { container } = render(
6064
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
61-
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' } as unknown as BaseSchema} />
65+
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' }} />
6266
</SchemaRendererContext.Provider>
6367
);
6468
expect(container.innerHTML).toBe('');
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `BaseSchema.visible` admits the predicate string the renderer evaluates
5+
* (objectui#4581).
6+
*
7+
* `BaseSchema` declared `visible?: boolean`. The renderer does not treat it as
8+
* a boolean — `packages/react/src/SchemaRenderer.tsx:382` reads:
9+
*
10+
* ```ts
11+
* if (newSchema.visible !== undefined) {
12+
* return !evaluator.evaluateCondition(newSchema.visible);
13+
* }
14+
* ```
15+
*
16+
* and `evaluateCondition` is declared
17+
* `(condition: string | boolean | undefined, context?) => boolean`
18+
* (`packages/core/dist/evaluator/ExpressionEvaluator.d.ts:143`). So a predicate
19+
* STRING is a supported, evaluated input — the sibling keys `visibleWhen` and
20+
* the deprecated `visibleOn` are both `string` for the same reason — and the
21+
* declared type simply under-reported it. Two fixtures in
22+
* `packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx` exercised
23+
* exactly that capability through `as unknown as BaseSchema`; PR #4578 is what
24+
* made the gap visible, by typing `schema` honestly for the first time.
25+
*
26+
* The widening is `boolean | string` — what `evaluateCondition` accepts, no
27+
* wider. It is authored-input dominant: authors write `visible`, and code that
28+
* READS `schema.visible` already had to cope with `any` through
29+
* `BaseSchema`'s index signature.
30+
*
31+
* ## Predictions, written before the first run (red-first)
32+
*
33+
* Against `origin/main` (`92250d648`), `tsc -p packages/types/tsconfig.test.json`
34+
* must report:
35+
*
36+
* 1. `predicateStringIsAuthorable` — TS2322, `Type 'string' is not
37+
* assignable to type 'boolean | undefined'`.
38+
* 2. `assertionVisible` — `Equal< BaseSchema['visible'], boolean | string |
39+
* undefined >` resolves `false`, failing `Expect`'s constraint (TS2344).
40+
*
41+
* After the fix both compile clean.
42+
*
43+
* `assertionVisible` is INVARIANT on purpose. A `satisfies`-style check, or a
44+
* one-way `extends`, would be vacuous here in both directions: the narrow
45+
* `boolean` is assignable to the wide `boolean | string`, so a widening that
46+
* never happened and a widening that overshot to `any` would both stay green.
47+
* Pinning the exact union is the only assertion that can go red for the right
48+
* reason — and `BaseSchema`'s `[key: string]: any` index signature makes the
49+
* overshoot a live risk rather than a hypothetical one, since deleting the
50+
* declared property altogether would leave `visible` typed `any` and every
51+
* fixture below still compiling.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import type { BaseSchema } from '../base';
56+
57+
/* ── Type-level helpers ──────────────────────────────────────────────────── */
58+
59+
/** Invariant equality — `extends` both ways would accept a narrowing. */
60+
type Equal< A, B > =
61+
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
62+
type Expect< T extends true > = T;
63+
64+
/* ── The declared type is exactly what the evaluator accepts ─────────────── */
65+
66+
export type assertionVisible = Expect<
67+
Equal< BaseSchema['visible'], boolean | string | undefined >
68+
>;
69+
70+
/* ── Authorable fixtures ─────────────────────────────────────────────────── */
71+
72+
/** The capability the renderer implements, now declared. */
73+
export const predicateStringIsAuthorable: BaseSchema = {
74+
type: 'test-component',
75+
visible: 'record.status == "open"',
76+
};
77+
78+
/** The template-expression spelling the shipped fixtures use. */
79+
export const templateExpressionIsAuthorable: BaseSchema = {
80+
type: 'test-component',
81+
visible: '${data.role === "admin"}',
82+
};
83+
84+
/** The boolean form is untouched — this is a widening, not a replacement. */
85+
export const booleanIsStillAuthorable: BaseSchema = {
86+
type: 'test-component',
87+
visible: false,
88+
};
89+
90+
/* ── Runtime companion ───────────────────────────────────────────────────── */
91+
92+
describe('BaseSchema.visible (objectui#4581)', () => {
93+
it('type-level: visible is boolean | string, pinned invariantly', () => {
94+
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
95+
// from this package's `type-check` script. The runtime case exists so a
96+
// green vitest run is not mistaken for the proof.
97+
expect(predicateStringIsAuthorable.visible).toBe('record.status == "open"');
98+
expect(booleanIsStillAuthorable.visible).toBe(false);
99+
});
100+
});

packages/types/src/base.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,21 @@ export interface BaseSchema {
102102
/**
103103
* Controls whether the component is visible.
104104
* When false, component is not rendered (display: none).
105+
*
106+
* Accepts a PREDICATE STRING as well as a boolean (objectui#4581): the
107+
* renderer does not read this key as a boolean, it evaluates it —
108+
* `SchemaRenderer.tsx:382` calls `evaluator.evaluateCondition(schema.visible)`,
109+
* and `evaluateCondition` is declared
110+
* `(condition: string | boolean | undefined, context?) => boolean`. The
111+
* sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for
112+
* the same reason; this one simply under-reported the capability, and
113+
* fixtures exercising it had to cast past the declaration.
114+
*
105115
* @default true
116+
* @example true
117+
* @example "${data.role === 'admin'}"
106118
*/
107-
visible?: boolean;
119+
visible?: boolean | string;
108120

109121
/**
110122
* Canonical conditional-visibility predicate (ADR-0089) — the element is shown

0 commit comments

Comments
 (0)