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
63 changes: 63 additions & 0 deletions .changeset/component-input-type-union-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/sdui-parser': minor
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
---

`ComponentInput.type` can declare a UNION, so a block stops warning about legal
writes its own description recommends

A registration's `type` was one coarse control kind, while a good number of spec
keys accept more than one shape. A declaration therefore had to pick an arm, and
the repo's own manifest gate then reported `type-mismatch` on the other arm's
legal values. Four of the five measured cases were the loud shape: the input's
`description` teaches the author to write an inline translation map
(`{ en, "zh-CN", … }`) while the same input's `type: 'string'` made
`sdui-parser`'s `checkType` warn about exactly that map — one platform authority
contradicting itself on the write it had just recommended. Because these land at
warning severity the page still compiled and rendered; the cost is that noise on
correct authoring trains authors, AI authors included, to dismiss the
`unknown-prop` and `type-mismatch` reports that are real.

`type` now accepts an ARRAY of coarse kinds as well as a single one (maintainer
ruling on objectui#3832, direction (a)), and a value passes the coarse check when
ANY declared arm accepts it. Both declaration sites in `@object-ui/types` move
together with the registry's own copy in `@object-ui/core`, and
`ComponentInputSchema` enforces the same widening — a non-empty array of
DISTINCT kinds, so an empty arm list or a repeated arm is refused at authoring
time rather than normalized behind the author's back.

Five declarations now spell their real contract, and the `type-mismatch` warning
on each of these legal writes is gone:

- `page:header.title`, `page:header.subtitle`, `page:card.title` —
string **or** inline translation map (the spec's union, measured against
`ComponentPropsMap` at the pinned rc.6; the renderers resolve both through
`pickLocalized`);
- `record:alert.title`, `record:alert.body` — the same two shapes, justified
against the RENDERER since the pinned spec carries no `record:alert` props
schema;
- `element:text_input.defaultValue` — `string | number`, the spec's union,
which had been narrowed to `'string'` with the number arm named only in prose.

**Backward compatible, and measured as such.** The single-kind form stays valid
and is still the canonical spelling for a one-arm key: it validates identically
(the diagnostics for one arm, `invalid-enum` and its `error` severity included,
are byte-identical), and `manifestFromConfigs` collapses a one-element array back
to the bare string, so every entry already in a published `sdui.manifest.json`
serializes unchanged and arrays appear only where a union was really declared.
The JSX authoring surface follows in the same step — `generateDts` emits a
TypeScript union for a union input, so the `.d.ts` an author type-checks against
accepts exactly what the gate accepts.

A union widens what is legal; it does not switch the check off. A value matching
NO declared arm is still reported, a multi-arm mismatch reports at its strictest
arm's severity (`error` when an `enum` arm is present, so an enum's closed list
does not become dismissible by having a second arm added next to it), and arms
are meant to match the contract rather than relax the gate:
`element:text_input.defaultValue` deliberately gains no `object` arm because the
spec rejects a map there, and `element:record_picker.emptyText` keeps its single
`'string'` arm because that renderer drops the map form (objectui#4163) — an arm
the renderer never honours would advertise a shape that cannot reach the screen.
186 changes: 186 additions & 0 deletions apps/console/src/__tests__/component-input-union-specimens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* The five measured specimens of objectui#3832: a `ComponentInput` whose spec
* type is a UNION, declared with one arm, so the repo's own manifest gate
* reported `type-mismatch` on the OTHER arm — a legal write.
*
* Four of them are the inline-translation-map shapes, and they are the loud
* ones: the input's own `description` teaches the author to write
* `{ en, "zh-CN" }` while the same input's `type: 'string'` made
* `sdui-parser`'s `checkType` warn about it. One platform authority, two halves,
* contradicting each other on the write it had just recommended. The fifth is
* `element:text_input.defaultValue`, whose spec type is `string | number`
* (measured against `ElementTextInputPropsSchema` in
* `packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts`).
*
* The maintainer ruling (2026-08-09) picked direction (a): `ComponentInput.type`
* learns to express unions, `checkType` passes when ANY arm matches, and these
* five declare their real unions. This file is the user-visible acceptance face
* of that ruling — it drives the SAME `manifestFromConfigs` + `validateTree`
* pair the JSX-page compiler (`packages/components/src/renderers/layout/page.tsx:462`)
* and the save gate use, over the registry the console really registers.
*
* ## Why every specimen comes with a control
*
* "No diagnostic" is also what a SILENCED check looks like, and this change is
* a widening: if `checkType` stopped reporting `type-mismatch` at all, or if the
* array-valued `type` fell through the switch into `default: return null` (which
* is exactly what the pre-#3832 `switch (input.type)` does when handed an
* array), every positive assertion here would still pass. So each specimen is
* paired with a value that matches NEITHER arm and must still be reported. That
* pairing is what the mutation runs in the PR body key off: reverting the
* any-arm logic alone leaves the positives GREEN (vacuously — the switch
* swallows the array) and turns the CONTROLS red.
*
* Module-scope imports, not `beforeAll` (AGENTS.md §测试纪律): the specimens
* resolve through registration side-effects, and paying that at import time
* keeps it out of every test/hook timeout budget.
*/
import { describe, it, expect } from 'vitest';
import { ComponentRegistry } from '@object-ui/core';
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser';
import '@object-ui/components';
import '../register-plugins';

/**
* The manifest built from the WHOLE registry, not just the public tier.
* `element:text_input` is `tier:'internal'` and so never reaches
* `sdui.manifest.json`, but `page.tsx:462` builds the JSX-page compiler's prop
* whitelist from `getKnownTypes()` + these same `inputs` — which is where its
* `defaultValue` diagnostic was reaching authors.
*/
const manifest = manifestFromConfigs(
ComponentRegistry.getAllConfigs() as unknown as Parameters<typeof manifestFromConfigs>[0],
);

const diagnose = (node: unknown): Diagnostic[] =>
validateTree(node as SchemaElement, manifest).diagnostics;

const codesFor = (node: unknown): string[] => diagnose(node).map((d) => d.code);

/** The inline translation map an author is told to write, verbatim from the descriptions. */
const I18N_MAP = { en: 'Account', 'zh-CN': '客户' };

const declaredArms = (type: string, input: string): string[] => {
const declared = manifest.components[type]?.inputs.find((i) => i.name === input)?.type;
return Array.isArray(declared) ? declared : [declared as string];
};

describe('objectui#3832 — the five measured specimens declare their real unions', () => {
it('every specimen block is registered (reachability before absence)', () => {
// Without this, a renamed or unregistered block would satisfy every
// "no type-mismatch" assertion below by never being validated at all —
// `unknown-component` is a different code, and the filters here are
// per-code by design.
for (const type of ['page:header', 'page:card', 'record:alert', 'element:text_input']) {
expect(manifest.components[type], `${type} is not registered`).toBeDefined();
}
});

it('`page:header.title` / `.subtitle` accept the inline translation map', () => {
// The spec union, measured: `ComponentPropsMap['page:header'].title` is
// `string | Record< string, string >` and `.subtitle` the same.
expect(declaredArms('page:header', 'title')).toEqual(
expect.arrayContaining(['string', 'object']),
);

const mapped = { type: 'page:header', title: I18N_MAP, subtitle: I18N_MAP };
expect(diagnose(mapped).filter((d) => d.code === 'type-mismatch')).toEqual([]);

// …and the plain-string arm keeps validating clean (that half must not move).
expect(
diagnose({ type: 'page:header', title: 'Account', subtitle: 'All accounts' }).filter(
(d) => d.code === 'type-mismatch',
),
).toEqual([]);

// CONTROL — a value matching NEITHER arm is still reported.
expect(codesFor({ type: 'page:header', title: true })).toContain('type-mismatch');
expect(codesFor({ type: 'page:header', title: 'ok', subtitle: 42 })).toContain(
'type-mismatch',
);
});

it('`page:card.title` accepts the inline translation map', () => {
expect(declaredArms('page:card', 'title')).toEqual(
expect.arrayContaining(['string', 'object']),
);
expect(
diagnose({ type: 'page:card', title: I18N_MAP }).filter((d) => d.code === 'type-mismatch'),
).toEqual([]);
expect(
diagnose({ type: 'page:card', title: 'Account' }).filter((d) => d.code === 'type-mismatch'),
).toEqual([]);

// CONTROL
expect(codesFor({ type: 'page:card', title: ['Account'] })).toContain('type-mismatch');
});

it('`record:alert.title` / `.body` accept the inline translation map', () => {
// These two have no spec props schema at rc.6 (`ComponentPropsMap` carries
// no `record:alert` entry — measured), so the union comes from the RENDERER:
// `record-alert.tsx:126-127` resolves both through `pickLocalized`, which is
// what the descriptions already teach.
expect(declaredArms('record:alert', 'title')).toEqual(
expect.arrayContaining(['string', 'object']),
);
const mapped = { type: 'record:alert', title: I18N_MAP, body: I18N_MAP };
expect(diagnose(mapped).filter((d) => d.code === 'type-mismatch')).toEqual([]);
expect(
diagnose({ type: 'record:alert', title: 'Overdue', body: 'Pay it' }).filter(
(d) => d.code === 'type-mismatch',
),
).toEqual([]);

// CONTROL
expect(codesFor({ type: 'record:alert', title: 7 })).toContain('type-mismatch');
});

it('`element:text_input.defaultValue` accepts the number arm', () => {
expect(declaredArms('element:text_input', 'defaultValue')).toEqual(
expect.arrayContaining(['string', 'number']),
);
expect(
diagnose({ type: 'element:text_input', defaultValue: 42, inputType: 'number' }).filter(
(d) => d.code === 'type-mismatch',
),
).toEqual([]);
expect(
diagnose({ type: 'element:text_input', defaultValue: 'acme' }).filter(
(d) => d.code === 'type-mismatch',
),
).toEqual([]);

// CONTROL — the spec rejects a boolean here (measured in the block's own
// spec-parity test), and so must the gate. The i18n map is a control too:
// `ElementTextInputPropsSchema` refuses it, so this key must NOT have
// acquired the `object` arm its neighbours got.
expect(codesFor({ type: 'element:text_input', defaultValue: true })).toContain(
'type-mismatch',
);
expect(codesFor({ type: 'element:text_input', defaultValue: I18N_MAP })).toContain(
'type-mismatch',
);
});

it('the narrowing that STAYS narrow is not swept up by the widening', () => {
// `element:record_picker.emptyText` is the counter-example, and it is the
// reason this widening is per-key rather than a blanket "strings may also be
// objects": rc.6 widened the contract to the `I18nLabel` union, but the
// renderer passes the value into a text node with no locale resolution
// (objectui#4163), so only the plain-string form renders. Declaring the
// object arm here would advertise a shape the renderer drops — the exact
// mistake this repo files as a false declaration, so `emptyText` keeps its
// single `'string'` arm until the render site catches up.
expect(declaredArms('element:record_picker', 'emptyText')).toEqual(['string']);
expect(codesFor({ type: 'element:record_picker', emptyText: I18N_MAP })).toContain(
'type-mismatch',
);
});
});
11 changes: 8 additions & 3 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,14 @@
* `record:details.sections`, `record:highlights.fields` and
* `record:related_list.add` publish their members in prose and are pinned by
* per-block tests next to their renderers. PR #3795's open question;
* - types. `ComponentInput.type` is one coarse control kind and cannot spell a
* spec union, so a key can be in perfect NAME parity while publishing a
* narrower type than the contract accepts (objectui#3832).
* - types. This gate compares key NAMES only, so a key can be in perfect name
* parity while publishing a type the contract does not match — narrower, or
* (since objectui#3832 gave `ComponentInput.type` the array form) wider by an
* arm the spec rejects. The expressiveness half of that gap is closed: a
* union key can now declare its real arms, and the five specimens do. The
* COMPARISON half is not, and is nobody's check yet — each of those five is
* pinned against the spec's verdicts by a per-block test next to its
* renderer, which is per-block discipline, not a gate.
*
* A pass means the top-level key names are in parity, nothing more.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,10 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () =>
});

it('the coarse `object` type costs nothing here — it accepts exactly what the spec accepts', () => {
// The `element:text_input.defaultValue` sibling had to name a narrowing in
// prose, because `ComponentInput.type` is one coarse control kind and the
// spec's type there is the union `string | number` (objectui#3832). This key
// is the case where the two agree exactly: `checkType`'s `'object'` arm in
// The `element:text_input.defaultValue` sibling declares TWO arms, because
// the spec's type there is the union `string | number` (objectui#3832). This
// key is the case where one arm agrees with the contract exactly:
// `checkType`'s `'object'` arm in
// `sdui-parser/src/validate.ts` passes a non-null non-array object and warns
// `type-mismatch` on everything else — the same partition `safeParse` draws
// above. Asserted through the real validator, not by reading its source, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,39 @@ describe('element:text_input — registry inputs vs @objectstack/spec', () => {
expect(defaultValueDescription()).not.toBe('');
});

it('names the number arm the coarse `type` cannot express', () => {
// The spec's type is the union `string | number`; `ComponentInput.type` is one
// coarse control kind, so `'string'` is a real narrowing —
// `sdui-parser`'s `checkType` warns `type-mismatch` on `defaultValue={42}`,
// which the spec accepts. The narrowing is not the thing being asserted (it
// is a `ComponentInput` limit, tracked as objectui#3832); what is asserted is
// that the description does not hide it, so an author reaching for a numeric
// default knows the key takes one and knows why the warning appears.
expect(ElementTextInputPropsSchema.safeParse({ defaultValue: 42 }).success).toBe(true);
expect(ElementTextInputPropsSchema.safeParse({ defaultValue: true } as never).success).toBe(false);

expect(input('defaultValue')?.type).toBe('string');
it('DECLARES both arms of the spec union, and only those arms', () => {
// Replaced rather than re-spelled (objectui#3832). This assertion used to
// read `input('defaultValue')?.type).toBe('string')` and its comment
// explained the narrowing that made it true: the spec's type is the union
// `string | number`, `ComponentInput.type` held one coarse kind, so the
// declaration picked an arm and named the other in prose while the manifest
// gate warned `type-mismatch` on `defaultValue={42}` — a value the spec
// accepts. Re-pointing that assertion at the new value would have pinned the
// declaration and left the FACT it existed for — the two must be the same
// two — unmeasured, so both halves are derived from the spec at runtime
// instead.
//
// The arms are compared as a SET against the spec's own verdicts: each arm
// must be a shape the spec accepts, and each shape the spec accepts must
// have an arm. That is what makes this red if either side moves — a spec
// that drops the number arm, or a declaration that adds an arm the spec
// rejects (the `'object'` inline-translation arm its neighbours carry is the
// live temptation, and the spec refuses it here).
const accepts = (value: unknown) =>
ElementTextInputPropsSchema.safeParse({ defaultValue: value } as never).success;

expect(accepts('acme')).toBe(true);
expect(accepts(42)).toBe(true);
expect(accepts(true)).toBe(false);
expect(accepts({ en: 'acme', 'zh-CN': '安客' })).toBe(false);

const declared = input('defaultValue')?.type;
const arms = Array.isArray(declared) ? declared : [declared];
expect([...arms].sort()).toEqual(['number', 'string']);

// The description still names the number arm. It is no longer carrying a
// narrowing the type could not express, but "string or number" is what an
// author reads before they read a manifest, and the two must not drift.
expect(defaultValueDescription()).toMatch(/number/);
});

Expand Down
Loading
Loading