|
| 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`). |
0 commit comments