Skip to content

Commit 5323168

Browse files
claude[bot]claude
andauthored
fix(types)!: retire ToastSchema.action from both published faces (objectui#8338) (#8540)
* fix(types)!: retire ToastSchema.action from both published faces (objectui#8338) `action` had NO satisfiable JSON inhabitant on the TypeScript face — `label` and `onClick` were both required and `onClick` is a function — while the zod mirror admitted `SchemaNode | SchemaNode[]`. Two published faces with disjoint accept sets, one of them empty: an author who wrote the key got a green `safeParse` and a `tsc` refusal, and no spelling satisfied both. The `toast` renderer read neither. ADR-0049 enforce-or-remove, retire route: `action?: never` on the declaration and `retirementTombstone()` on the mirror. Not `handlerKeyRefusal()` — `action` is a value key whose nested member was a function, which is why objectui#6124's top-level sweep walked past it and retired only `onDismiss`, four lines down. There is no replacement spelling and the capability was never fulfilled: objectui#6250 moved the toast demos off an in-toast action entirely. The parity ledgers drain with it: `KnownDrift` 42/64 -> 41/63 and `WiderThanDeclared` 23/36/47 (6/30/0/11) -> 22/35/45 (6/29/0/10), every figure re-derived by the file's own AST and mirror instruments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE * docs(changeset): declare the ToastSchema.action retirement (objectui#8338) Graded minor with the breaking-for-authored-metadata wording PR #7774 established: the mirror's accept set narrows (a node, and a list of nodes, no longer parse) and the TypeScript face's `{ label, onClick }` literal stops compiling. Both before/after columns are measured by ablating the tombstone back to the base tree's declarations and re-running the pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE * fix(types): respell the action tombstone so the T2 widening tell stops firing, and pin the primitive arms it hid (objectui#8338) Three changes, no behaviour delta on the first, a strictly larger pin on the rest. 1. The tombstone's first message fragment moves onto the `retirementTombstone(` call line. An indented bare quoted string that ENDS a line is regex-identical to a closed-set member for `check-widening-tells.mjs`'s `BARE_STRING_ELEMENT`, so a NARROWING was reported as one widening tell (exactly one, because every continuation carries a leading `+ ` — which is the proof of the diagnosis). The emitted string is asserted byte-identical: sha256 10b0b510c8b4ece4ab594373f870644dcefb9816b9bc92608b0791a060190fcc, len 732, read off a rebuilt dist before and after, and the parse-time issue message still equals `.describe()`. 2. `RETIRED_MIRROR_SHAPES` gains the primitive arms. Re-derived by re-forming `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` around the SHIPPED `SchemaNodeSchema` (which is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])`): the old mirror ALSO accepted `'Undo'`, `1`, `true`, `null`, `[]` and `['a', 1]`. That is the larger half of this narrowing and it was unpinned. 3. The shape list's MIXEDness is now pinned. vitest spreads an `it.each` case only when `cases.every(Array.isArray)`, so the list arm arriving as one argument depended on an undeclared property of the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE * docs(changeset): narrow an over-wide census sentence to what was measured (objectui#8338) The note claimed no `.tsx` / `.mdx` / `.ts` source authors an `action` object. Literally false as written: `app-shell/src/chrome/notificationToast.tsx:76` and `chrome/toast-helpers.ts:71` both write `action: { label, onClick }` — for SONNER's runtime API (`import { toast } from 'sonner'`), a different interface that never fed this key. The conclusion is unchanged (0 of 7 authored `toast` nodes carry `action`); only the sentence was wider than the measurement, and a measured zero must not be carried forward wider than what was counted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c46944 commit 5323168

5 files changed

Lines changed: 287 additions & 41 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
**Breaking for already-authored metadata:** `ToastSchema.action` is RETIRED on both
6+
published faces (objectui#8338, ADR-0049 enforce-or-remove).
7+
8+
**What was wrong.** The two faces shared nothing, and one of them had no JSON
9+
inhabitant at all. `packages/types/src/feedback.ts` declared
10+
`action?: { label: string; onClick: () => void }` — both members REQUIRED and
11+
`onClick` a function, so a JSON document could omit the key but never author it —
12+
while `packages/types/src/zod/feedback.zod.ts` declared
13+
`z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, a node or a list of nodes.
14+
Disjoint accept sets, one of them empty: the same document got a green `safeParse`
15+
and a `tsc` refusal, and no spelling satisfied both. `renderers/feedback/toast.tsx`
16+
read NEITHER — it reads `variant`, `title`, `description`, `duration`,
17+
`buttonVariant`, `className` and `buttonLabel`, and the file has exactly one
18+
`ComponentRegistry.register` call, so the zero is a reading and not a failed scan.
19+
20+
This is objectui#6496's Direction 2, the half its `completed` close left behind:
21+
that card measured the key, prescribed enforce-or-remove, and was closed by PR #6542,
22+
which is Direction 1 only (declaring `buttonLabel` / `buttonVariant`). The sibling
23+
`onDismiss` was finished separately under objectui#6124 and sits four lines down as
24+
an ADR-0049 tombstone whose prose gives, word for word, the reason `action` should
25+
have gone with it. `action` survived only because that sweep was over TOP-LEVEL
26+
function-valued keys and this key's function is one level down.
27+
28+
**The accept set moves in one direction only — on the MIRROR.** Measured by ablating
29+
the tombstone back to the base tree's two declarations (`3bc187b7f`) and re-running
30+
the pins, then restoring and proving both files byte-identical to `HEAD`:
31+
32+
| `{ "type": "toast", "action": … }` | before | after |
33+
| --- | --- | --- |
34+
| `{ "type": "button", "label": "Undo" }` — a node | **accepted** | refused, `invalid_type` at `action` |
35+
| `[{ "type": "button" }]` — a list of nodes | **accepted** | refused, `invalid_type` at `action` |
36+
| `{ "label": "Undo", "onClick": … }` — the TS face's own shape, and `{}` | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` |
37+
| `"Undo"`, `1`, `true`, `null`, `[]`, `["a", 1]` — bare primitives, and lists of them | **accepted** | refused, `invalid_type` at `action` |
38+
| `[[{ "type": "button" }]]` — a nested list | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` |
39+
| the key omitted, or an explicit `undefined` | accepted | accepted |
40+
41+
⭐ The primitive row is the LARGER half of this narrowing and is the easy one to miss: `SchemaNodeSchema` is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])` (`zod/base.zod.ts:84`), so `action: "Undo"` parsed green under the old union exactly as a node object did. Re-derived by re-forming the old spelling around the SHIPPED `SchemaNodeSchema` rather than a hand-rebuilt one, with a non-vacuity control: the two envelopes must disagree on the node case, else the probe is comparing a schema with itself.
42+
43+
Nothing went from refused to accepted. The key stays DECLARED rather than deleted,
44+
because `BaseSchema` is `.passthrough()`: removing the member would KEEP an authored
45+
value unvalidated and silently inert instead of refusing it. `retirementTombstone()`
46+
writes ONE guidance string into BOTH author-facing channels — the parse-time issue
47+
message and `.describe()` — so they cannot drift.
48+
49+
**The compile-time face, which a TypeScript author meets first.** A `.tsx` author
50+
COULD write `action: { label, onClick }` against the old declaration and it compiled;
51+
it is now `action?: never`, so the same literal fails type-check. That is the only
52+
place this narrowing removes something that ever worked, and it worked only in the
53+
programmatic channel — no JSON document could ever hold it, and no renderer read it.
54+
55+
**Not `handlerKeyRefusal()`.** `action` is ⛔ not a handler key: it is a VALUE key
56+
whose NESTED member was a function. So it takes the ADR-0049 retirement helper
57+
(`invalid_type`, and `?: never` on the declaration) and not objectui#6124's
58+
named-refusal arm (`custom`, with a TS twin that stays callable for a runtime slot).
59+
The two helpers are pinned apart in `handler-keys-json-refusal-6124.test.ts`, whose
60+
census counts 45 runtime slots + 22 retired `on*` sites — a non-handler key has no
61+
seat in it.
62+
63+
**Migration: there is NONE, and the capability was never fulfilled.** ⛔ Not "not yet
64+
supported" and ⛔ not a pointer at a future shape. objectui#6250's close moved the
65+
seven toast demos off an in-toast action entirely; an in-toast action button remains
66+
a capability expansion with zero runtime and would need its own card. Raise the toast
67+
from the node itself (`title`, `description`, `variant`, `duration`) and label its
68+
trigger with `buttonLabel` / `buttonVariant`. Measured across this repository: of the
69+
7 authored `toast` nodes in tracked JSON, **0** carry `action` (control: the same walk
70+
finds all 7 nodes), and no source file authors one **on a `toast` node**. ⚠️ Stated that narrowly on purpose: `app-shell/src/chrome/notificationToast.tsx:76` and `chrome/toast-helpers.ts:71` DO write `action: { label, onClick }` — for **sonner's** runtime API (`import { toast } from 'sonner'`), a different interface this key never fed. A flat "no source authors one" would be literally false.
71+
72+
**⛔ `EmptySchema.action` is untouched** (objectui#7105 / PR #8330) — the same word on
73+
a sibling schema in the same file, with the deliberately opposite disposition, and its
74+
own comment block explains that it refuses the `{ label, onClick }` shape this key
75+
spelled. Two different interfaces, on purpose.
76+
77+
**The parity ledgers drain with it,** every figure re-derived by
78+
`zod-mirror-parity.test.ts`'s own AST and mirror instruments rather than stepped by
79+
hand: `KnownDrift` 42 entries / 64 keys → **41 / 63** (the entry's whole content, so
80+
the entry went too — the ledger's first loss by RETIRING a key rather than by moving
81+
either face toward the other), and `WiderThanDeclared` 23 / 36 / 47 arms, split
82+
6 / 30 / 0 / 11 → **22 / 35 / 45**, split **6 / 29 / 0 / 10**. The pair itself stays
83+
registered, so `EXPECTED_MIRROR_PAIRS` does not move.
84+
85+
Graded `minor`, not `patch`: a published accept set narrows. Not `major` per this
86+
repo's fixed-group convention — objectui's own breaking changes ship as `minor` and
87+
the group's major tracks `@objectstack` (AGENTS.md 版本号策略, enforced by
88+
`scripts/check-changeset-no-major.mjs`).

‎packages/types/src/__tests__/toast-button-keys.test.ts‎

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,34 @@
2121
* by the renderer and named by nothing at all.
2222
*
2323
* `SonnerSchema` — the sibling component with the same trigger mechanism —
24-
* declared both all along. This card is the declare-what-runs half only;
24+
* declared both all along. objectui#6496 was the declare-what-runs half only;
2525
* `action` / `onDismiss` (declared-but-unread, the other direction the finding
26-
* recorded) are deliberately untouched here and stay with the objectui#6124 /
27-
* objectui#6182 handler-dialect family.
26+
* recorded) were left to the objectui#6124 / objectui#6182 handler-dialect
27+
* family, and `onDismiss` did land there.
28+
*
29+
* ## `action` came back here, and NOT to the handler family (objectui#8338)
30+
*
31+
* ⚠️ The sentence above used to say `action` was deliberately untouched here.
32+
* That is no longer true, and the reason it moved is worth stating: `action` is
33+
* ⛔ NOT a handler key. It is a VALUE key whose NESTED member was a function,
34+
* which is exactly why objectui#6124's sweep — over TOP-LEVEL function-valued
35+
* keys — walked past it and retired only `onDismiss`, four lines down. So it
36+
* takes `retirementTombstone()` (an ADR-0049 retirement from the contract on
37+
* both faces: `invalid_type`, `?: never`) and ⛔ not `handlerKeyRefusal()` (the
38+
* #6124 named-refusal arm: `custom`, and a TS twin that stays callable for a
39+
* runtime slot). `handler-keys-json-refusal-6124.test.ts` pins that family and
40+
* its census asserts 45 runtime slots + 22 retired `on*` sites; a non-handler
41+
* key has no seat in it. This file — the `ToastSchema` declaration pin — is the
42+
* home, and the two helpers are pinned APART there, deliberately.
43+
*
44+
* What was wrong with `action`, measured before the retirement: the TS face
45+
* declared `{ label: string; onClick: () => void }` with BOTH members required,
46+
* so no JSON value satisfied it, while the mirror admitted
47+
* `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`. Disjoint accept
48+
* sets, one of them empty — a green `safeParse` and a `tsc` refusal for the
49+
* same document, and no spelling that satisfied both. The renderer read
50+
* neither. ⛔ There is NO replacement spelling: objectui#6250 moved the toast
51+
* demos off an in-toast action entirely and the capability was never fulfilled.
2852
*
2953
* ## Why `buttonVariant` is an enum and not `z.string()`
3054
*
@@ -208,3 +232,111 @@ describe('ToastSchema (TS) — compile-time pin on the same keys', () => {
208232
expect([...every, extra]).toHaveLength(7);
209233
});
210234
});
235+
236+
/* ── `action` is RETIRED on both faces (objectui#8338, ADR-0049) ──────────── */
237+
238+
/** The shape the TS face declared: the one no JSON document could ever hold. */
239+
const RETIRED_TS_SHAPE = { label: 'Undo', onClick: () => undefined };
240+
/** What the MIRROR admitted: `SchemaNodeSchema | SchemaNodeSchema[]` — and that
241+
* node union is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(),
242+
* z.null(), z.undefined()])` (`../zod/base.zod.ts`), so BARE PRIMITIVES parsed
243+
* green here too, not just node objects. Re-derived by re-forming the old union
244+
* around the SHIPPED `SchemaNodeSchema`; the primitive arms are the larger half
245+
* of the narrowing and were missing from the first reading of it.
246+
* ⚠️ This list must stay MIXED — vitest spreads an `it.each` case only when
247+
* `cases.every(Array.isArray)`, and the list arm has to arrive as ONE argument.
248+
* Pinned below: it is a property of the DATA that nothing else here would miss. */
249+
const RETIRED_MIRROR_SHAPES = [{ type: 'button', label: 'Undo' }, [{ type: 'button' }], 'Undo', 1, null];
250+
251+
describe('ToastSchema — `action` is retired, not deleted (objectui#8338)', () => {
252+
it('the mirror still DECLARES the key — a deletion would be a silent accept', () => {
253+
// `BaseSchema` is `.passthrough()`, so removing the member would KEEP an
254+
// authored value unvalidated instead of refusing it. The tombstone is the
255+
// whole point: the key stays declared and is unwritable.
256+
expect(Object.keys(ToastSchema.shape)).toContain('action');
257+
});
258+
259+
it('refuses the object the TS face used to declare, at the `action` path', () => {
260+
const result = ToastSchema.safeParse({ ...MINIMAL, action: RETIRED_TS_SHAPE });
261+
expect(result.success).toBe(false);
262+
if (result.success) return;
263+
const issue = result.error.issues.find((i) => String(i.path[0]) === 'action');
264+
expect(issue, 'no issue addressed to `action`').toBeDefined();
265+
// `invalid_type`, the tombstone's code — ⛔ not `custom`, which is what
266+
// `handlerKeyRefusal()` (the neighbour on `onDismiss`) reports.
267+
expect(issue!.code).toBe('invalid_type');
268+
expect(issue!.path).toEqual(['action']);
269+
});
270+
271+
it('the shape list stays MIXED, so `it.each` hands each case over whole', () => {
272+
expect(RETIRED_MIRROR_SHAPES.every(Array.isArray)).toBe(false);
273+
});
274+
275+
it.each(RETIRED_MIRROR_SHAPES)('refuses the shape the mirror used to admit: %j', (authored) => {
276+
// ⚠️ THE breaking assertion. Every one of these parsed GREEN before this
277+
// card, so a document already authored this way stops parsing. Ablate the
278+
// tombstone back to `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`
279+
// and this block goes green again — which is what makes it a pin.
280+
const result = ToastSchema.safeParse({ ...MINIMAL, action: authored });
281+
expect(result.success, `still accepts ${JSON.stringify(authored)}`).toBe(false);
282+
});
283+
284+
it('carries its guidance in BOTH author-facing channels, as ONE string', () => {
285+
// The `retirementTombstone()` invariant: the parse-time message and the
286+
// `.describe()` metadata are the same argument, so they cannot drift.
287+
const result = ToastSchema.safeParse({ ...MINIMAL, action: RETIRED_TS_SHAPE });
288+
expect(result.success).toBe(false);
289+
if (result.success) return;
290+
const message = result.error.issues.find((i) => String(i.path[0]) === 'action')!.message;
291+
const described = (ToastSchema.shape.action as { description?: string }).description;
292+
expect(message).toBe(described);
293+
expect(message).toContain('RETIRED (objectui#8338');
294+
// The remedy is that there ISN'T one — ⛔ not "not yet supported", and ⛔ not
295+
// a future shape. The tombstone says the capability was never fulfilled and
296+
// points at the keys that DO run.
297+
expect(message).toContain('NO replacement spelling');
298+
expect(message).toContain('buttonLabel');
299+
});
300+
301+
it('a toast without `action` still parses — the refusal is about the key, not the node', () => {
302+
// Counter-probe. Without it a mirror broken outright would satisfy every
303+
// refusal above. The seven published fixtures in
304+
// `examples/schema-catalog/src/schemas/components-feedback-toast/` are this
305+
// shape, and objectui#6250 already moved them off in-toast action.
306+
const result = ToastSchema.safeParse({ ...MINIMAL, title: 'Saved', buttonLabel: 'Undo' });
307+
expect(result.success ? null : result.error.issues).toBe(null);
308+
});
309+
});
310+
311+
describe('ToastSchema (TS) — `action` is a `?: never` tombstone (objectui#8338)', () => {
312+
it('refuses the object the declaration used to carry', () => {
313+
// Compiled by `tsconfig.test.json` (objectui#3009), so this directive is
314+
// real enforcement. It fails the build with TS2578 the moment the member is
315+
// DELETED rather than tombstoned: the key then resolves to `any` through
316+
// `BaseSchema`'s index signature and the assignment starts succeeding.
317+
// @ts-expect-error — `action` is retired; the type is `never`.
318+
const action: ToastSchemaTS['action'] = { label: 'Undo', onClick: () => undefined };
319+
// @ts-expect-error — and the node shape the mirror used to admit is not it either.
320+
const node: ToastSchemaTS['action'] = { type: 'button' };
321+
expect([action, node]).toHaveLength(2);
322+
});
323+
324+
it('reads as exactly `undefined` off the interface', () => {
325+
// The type-level half runs at `tsc`; this body only keeps the assertion
326+
// reachable from a test name. `Equal`, ⛔ not `extends`: a DELETED member
327+
// reads `any` through the index signature and a one-way check would accept
328+
// it — the same trap `handler-keys-json-refusal-6124.test.ts` names.
329+
const absent: ToastSchemaTS = { type: 'toast' };
330+
expect('action' in absent).toBe(false);
331+
});
332+
});
333+
334+
/* ── The TypeScript face, judged by `tsc -p tsconfig.test.json` ──────────── */
335+
336+
type Equal<A, B> =
337+
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
338+
type Expect<T extends true> = T;
339+
340+
export type assertionToastActionIsTombstoned = [
341+
Expect<Equal<ToastSchemaTS['action'], undefined>>,
342+
];

0 commit comments

Comments
 (0)