Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/base-schema-visible-predicate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/types': minor
---

`BaseSchema.visible` accepts the predicate string the renderer evaluates

`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` simply under-reported a capability it already had, and
fixtures exercising it had to cast past the declaration.

Now `boolean | string` — exactly what the evaluator accepts, no wider.

Graded **minor** by position analysis of the published `.d.ts`: the only diff is
`visible?: boolean` becoming `visible?: boolean | string` on an
authored-input-dominant property, with no union member removed and no other
declaration touched — the same shape as #4586/#4591. Authors gain a spelling;
nothing that previously type-checked stops doing so. Code that READS
`schema.visible` was already coping with `any` through `BaseSchema`'s index
signature.
18 changes: 11 additions & 7 deletions packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
// `@object-ui/types` declares `BaseSchema.visible` / `.disabled` as `boolean`,
// but BOTH accept a predicate STRING here — that is the capability these cases
// exercise, and the renderer evaluates it (`evaluateCondition`). The declaration
// is the narrow one; until it is widened these fixtures state their real shape
// through `BaseSchema`'s index signature (objectui#4548 measured the gap).
// `BaseSchema.visible` now declares `boolean | string` (objectui#4581), so the
// two visibility cases below state their predicate strings directly — no cast.
//
// `.disabled` is the SAME gap and is NOT yet widened: the renderer evaluates it
// through the same `evaluateCondition` (`SchemaRenderer.tsx:466`) and the
// `disabledOn?: string` sibling exists for the same reason, but #4581 named only
// `visible` and `ariaLabel`, so widening `disabled` was left to its own card
// rather than taken unruled. The two `disabled` casts below are what remains of
// the gap — drop them when that lands.
import type { BaseSchema } from '@object-ui/types';
import { SchemaRendererContext } from '../context/SchemaRendererContext';

Expand Down Expand Up @@ -49,7 +53,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('evaluates visible expression string', () => {
render(
<SchemaRendererContext.Provider value={{ dataSource: { role: 'admin' } }}>
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' } as unknown as BaseSchema} />
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' }} />
</SchemaRendererContext.Provider>
);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
Expand All @@ -58,7 +62,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('hides when visible expression evaluates to false', () => {
const { container } = render(
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' } as unknown as BaseSchema} />
<SchemaRenderer schema={{ type: 'test-component', visible: '${data.role === "admin"}' }} />
</SchemaRendererContext.Provider>
);
expect(container.innerHTML).toBe('');
Expand Down
100 changes: 100 additions & 0 deletions packages/types/src/__tests__/base-schema-visible-predicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `BaseSchema.visible` admits the predicate string the renderer evaluates
* (objectui#4581).
*
* `BaseSchema` declared `visible?: boolean`. The renderer does not treat it as
* a boolean — `packages/react/src/SchemaRenderer.tsx:382` reads:
*
* ```ts
* 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`). So a predicate
* STRING is a supported, evaluated input — the sibling keys `visibleWhen` and
* the deprecated `visibleOn` are both `string` for the same reason — and the
* declared type simply under-reported it. Two fixtures in
* `packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx` exercised
* exactly that capability through `as unknown as BaseSchema`; PR #4578 is what
* made the gap visible, by typing `schema` honestly for the first time.
*
* The widening is `boolean | string` — what `evaluateCondition` accepts, no
* wider. It is authored-input dominant: authors write `visible`, and code that
* READS `schema.visible` already had to cope with `any` through
* `BaseSchema`'s index signature.
*
* ## Predictions, written before the first run (red-first)
*
* Against `origin/main` (`92250d648`), `tsc -p packages/types/tsconfig.test.json`
* must report:
*
* 1. `predicateStringIsAuthorable` — TS2322, `Type 'string' is not
* assignable to type 'boolean | undefined'`.
* 2. `assertionVisible` — `Equal< BaseSchema['visible'], boolean | string |
* undefined >` resolves `false`, failing `Expect`'s constraint (TS2344).
*
* After the fix both compile clean.
*
* `assertionVisible` is INVARIANT on purpose. A `satisfies`-style check, or a
* one-way `extends`, would be vacuous here in both directions: 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.
* Pinning the exact union is the only assertion that can go red for the right
* reason — and `BaseSchema`'s `[key: string]: any` index signature makes the
* overshoot a live risk rather than a hypothetical one, since deleting the
* declared property altogether would leave `visible` typed `any` and every
* fixture below still compiling.
*/

import { describe, it, expect } from 'vitest';
import type { BaseSchema } from '../base';

/* ── Type-level helpers ──────────────────────────────────────────────────── */

/** Invariant equality — `extends` both ways would accept a narrowing. */
type Equal< A, B > =
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Expect< T extends true > = T;

/* ── The declared type is exactly what the evaluator accepts ─────────────── */

export type assertionVisible = Expect<
Equal< BaseSchema['visible'], boolean | string | undefined >
>;

/* ── Authorable fixtures ─────────────────────────────────────────────────── */

/** The capability the renderer implements, now declared. */
export const predicateStringIsAuthorable: BaseSchema = {
type: 'test-component',
visible: 'record.status == "open"',
};

/** The template-expression spelling the shipped fixtures use. */
export const templateExpressionIsAuthorable: BaseSchema = {
type: 'test-component',
visible: '${data.role === "admin"}',
};

/** The boolean form is untouched — this is a widening, not a replacement. */
export const booleanIsStillAuthorable: BaseSchema = {
type: 'test-component',
visible: false,
};

/* ── Runtime companion ───────────────────────────────────────────────────── */

describe('BaseSchema.visible (objectui#4581)', () => {
it('type-level: visible is boolean | string, pinned invariantly', () => {
// Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
// from this package's `type-check` script. The runtime case exists so a
// green vitest run is not mistaken for the proof.
expect(predicateStringIsAuthorable.visible).toBe('record.status == "open"');
expect(booleanIsStillAuthorable.visible).toBe(false);
});
});
14 changes: 13 additions & 1 deletion packages/types/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,21 @@ export interface BaseSchema {
/**
* Controls whether the component is visible.
* When false, component is not rendered (display: none).
*
* Accepts a PREDICATE STRING as well as a boolean (objectui#4581): the
* renderer does not read this key as a boolean, it evaluates it —
* `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
* the same reason; this one simply under-reported the capability, and
* fixtures exercising it had to cast past the declaration.
*
* @default true
* @example true
* @example "${data.role === 'admin'}"
*/
visible?: boolean;
visible?: boolean | string;

/**
* Canonical conditional-visibility predicate (ADR-0089) — the element is shown
Expand Down
Loading