From 076ed3b512386a4d162381dbc7e01c8a027a1f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:39:45 +0000 Subject: [PATCH] fix(examples,docs): correct the toast demos to spellings the engine executes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven `components-feedback-toast/*` and seven `components-feedback-sonner/*` `SchemaExample` fixtures hung an action object off `onClick`: { "type": "button", "label": "Destructive Toast", "variant": "destructive", "onClick": { "action": "toast", "variant": "error", "title": "Error", … } } `ButtonSchema.onClick` is `z.function()`, so all fourteen were a RED `safeParse` on the ENVELOPE — measured before the change, `{"code": "invalid_type","expected":"function","path":["onClick"],"message":"Invalid input: expected function, received object"}` on 14 of 14. And nothing read a handler key as an action object: `ActionRunner`'s runnable vocabulary is `script | url | modal | flow | api | form | navigation`, with no `toast` and no `sonner`. The docs page prints `JSON.stringify(schema)` beside each demo, so that shape was the copy-paste surface for every reader. It was worse than inert. `onClick` is a member of `SDUI_DOM_PASS_THROUGH_KEYS`, so the action object reached the rendered button's DOM listener slot, and React refuses it on click: "Expected `onClick` listener to be a function, instead got a value of `object` type." All fourteen now author the registered spellings the engine already executes — `type: 'toast'` and `type: 'sonner'` — whose renderers draw their own trigger button and call sonner's `toast()` from it. Keys are limited to those the governing schema declares AND the renderer reads; `buttonLabel` / `buttonVariant` are used on `sonner` (SonnerSchema declares both) and not on `toast` (ToastSchema declares neither, though its renderer reads them). `feedback/toast.mdx` and `feedback/sonner.mdx` follow, including two keys sonner's page taught that neither `SonnerSchema` declares nor its renderer reads (`duration`, `action`). Not done here, both left to the maintainer: declaring an action union on `ButtonSchema.onClick` with a dispatcher behind it, and giving the toast renderers the in-toast action button and promise form the removed demos implied. Both are capability expansions with zero runtime today. Part of #6250 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6250-toast-demo-shapes.md | 18 ++ content/docs/components/feedback/sonner.mdx | 23 +- content/docs/components/feedback/toast.mdx | 18 +- examples/schema-catalog/src/catalog-meta.json | 45 ++++ examples/schema-catalog/src/index.ts | 25 ++- .../basic-sonner-toast.json | 9 +- .../components-feedback-sonner/error.json | 13 +- .../components-feedback-sonner/info.json | 11 +- .../promise-based-toast.json | 14 +- .../components-feedback-sonner/success.json | 11 +- .../toast-with-action.json | 15 +- .../components-feedback-sonner/warning.json | 11 +- .../basic-toast.json | 10 +- .../components-feedback-toast/default.json | 11 +- .../destructive.json | 13 +- .../error-toast.json | 13 +- .../success-toast.json | 13 +- .../toast-with-action.json | 12 +- .../toast-with-undo.json | 12 +- .../component-fixture-declared-keys.test.ts | 149 +++++++++---- .../test/form-control-dom-leak-5632.test.tsx | 7 +- .../test/toast-demo-dispatch-6250.test.tsx | 197 ++++++++++++++++++ 22 files changed, 474 insertions(+), 176 deletions(-) create mode 100644 .changeset/6250-toast-demo-shapes.md create mode 100644 examples/schema-catalog/test/toast-demo-dispatch-6250.test.tsx diff --git a/.changeset/6250-toast-demo-shapes.md b/.changeset/6250-toast-demo-shapes.md new file mode 100644 index 0000000000..88aa0717a3 --- /dev/null +++ b/.changeset/6250-toast-demo-shapes.md @@ -0,0 +1,18 @@ +--- +--- + +Docs and fixtures only: the seven `components-feedback-toast/*` and seven +`components-feedback-sonner/*` `SchemaExample` fixtures hung an action object off +`onClick` (`{"type":"button", …, "onClick":{"action":"toast", …}}`) — a shape +`ButtonSchema` declares as a FUNCTION and no dispatcher reads, so all fourteen were a +RED `safeParse` on the envelope and clicking the rendered demo raised no toast +(objectui#6250). They now author the registered spellings the engine already executes, +`type: 'toast'` and `type: 'sonner'`, whose renderers draw their own trigger button and +call sonner's `toast()` from it. `feedback/toast.mdx` and `feedback/sonner.mdx` follow, +including two keys sonner's page taught that neither `SonnerSchema` declares nor its +renderer reads (`duration`, `action`). + +No package source or behaviour change; fixtures, docs and pins only. Two things the +fixture correction deliberately does NOT do, both left to the maintainer: declare an +action union on `ButtonSchema.onClick` and build a dispatcher for it, and give the toast +renderers the in-toast action button and promise form the removed demos implied. diff --git a/content/docs/components/feedback/sonner.mdx b/content/docs/components/feedback/sonner.mdx index d3867eebab..185724b609 100644 --- a/content/docs/components/feedback/sonner.mdx +++ b/content/docs/components/feedback/sonner.mdx @@ -9,6 +9,11 @@ The Sonner component provides beautiful toast notifications with a rich API. ## Basic Usage +A `sonner` node renders its own trigger button. Clicking the button raises the toast the +node declares, so the whole demo is one schema node with no handler to wire up. +`buttonLabel` and `buttonVariant` style that trigger; `message`, `description` and +`variant` describe the toast it raises. + ## Toast Types @@ -20,7 +25,9 @@ The Sonner component provides beautiful toast notifications with a rich API. -## With Action +## With Description + +`description` adds a secondary line under the message. @@ -29,21 +36,19 @@ The Sonner component provides beautiful toast notifications with a rich API. ```plaintext interface SonnerSchema { type: 'sonner'; - message: string; // Toast message + message?: string; // Toast message + title?: string; // Alias for message description?: string; // Additional description variant?: 'default' | 'success' | 'error' | 'warning' | 'info'; - duration?: number; // Auto-close duration (ms) - // Action - action?: { - label: string; - onClick: string | ActionConfig; - }; + // Trigger button + buttonLabel?: string; // Trigger button text (default: 'Show Toast') + buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; } ``` ## Examples -### Promise Toast +### Custom Trigger Button diff --git a/content/docs/components/feedback/toast.mdx b/content/docs/components/feedback/toast.mdx index b67e1391f1..4440f2d5ed 100644 --- a/content/docs/components/feedback/toast.mdx +++ b/content/docs/components/feedback/toast.mdx @@ -9,19 +9,25 @@ The Toast component displays brief notifications to users that appear temporaril ## Basic Usage +A `toast` node renders its own trigger button. Clicking the button raises the toast the +node declares — the title, description, variant and duration are all read off the node +itself, so the whole demo is one schema node with no handler to wire up. + ## Variants +`variant` selects the toast style. `ToastSchema` declares exactly five members — +`default`, `success`, `warning`, `error` and `info`; the four below plus `success`, +which the Success Message example uses. + + + -## With Action - - - ## Schema ```plaintext @@ -51,7 +57,3 @@ interface ToastSchema { ### Error Message - -### With Undo Action - - diff --git a/examples/schema-catalog/src/catalog-meta.json b/examples/schema-catalog/src/catalog-meta.json index 892662de1f..962f765a3d 100644 --- a/examples/schema-catalog/src/catalog-meta.json +++ b/examples/schema-catalog/src/catalog-meta.json @@ -36,6 +36,51 @@ "verification" ] }, + "components-feedback-sonner/promise-based-toast": { + "title": "Custom Trigger Button", + "description": "A success toast whose trigger button is styled through buttonVariant.", + "tags": [ + "toast", + "sonner", + "button" + ] + }, + "components-feedback-sonner/toast-with-action": { + "title": "Message With Description", + "description": "A sonner toast carrying both a message and a secondary description line.", + "tags": [ + "toast", + "sonner", + "description" + ] + }, + "components-feedback-toast/destructive": { + "title": "Error Variant", + "description": "The 'error' toast variant. 'destructive' is a button variant, not a toast variant.", + "tags": [ + "toast", + "variant", + "error" + ] + }, + "components-feedback-toast/toast-with-action": { + "title": "Info Variant", + "description": "The 'info' toast variant, used here for an update notice.", + "tags": [ + "toast", + "variant", + "info" + ] + }, + "components-feedback-toast/toast-with-undo": { + "title": "Warning Variant", + "description": "The 'warning' toast variant, used here for a deletion notice.", + "tags": [ + "toast", + "variant", + "warning" + ] + }, "components-form-form/basic-form": { "title": "Basic Form", "description": "A hand-built `form` node from `@object-ui/components`: name, email, country and newsletter fields declared inline, with no object behind them. For a form generated from an object's own metadata, see the `plugin-form` examples.", diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index d3bffaee23..b62c0857c8 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -1658,9 +1658,10 @@ const REGISTRY: Record = { 'components-feedback-sonner/promise-based-toast': { id: 'components-feedback-sonner/promise-based-toast', meta: { - title: "Promise Based Toast", - description: "", + title: "Custom Trigger Button", + description: "A success toast whose trigger button is styled through buttonVariant.", category: 'components-feedback-sonner', + tags: ["toast", "sonner", "button"], }, schema: components_feedback_sonner_promise_based_toast, }, @@ -1676,9 +1677,10 @@ const REGISTRY: Record = { 'components-feedback-sonner/toast-with-action': { id: 'components-feedback-sonner/toast-with-action', meta: { - title: "Toast With Action", - description: "", + title: "Message With Description", + description: "A sonner toast carrying both a message and a secondary description line.", category: 'components-feedback-sonner', + tags: ["toast", "sonner", "description"], }, schema: components_feedback_sonner_toast_with_action, }, @@ -1766,9 +1768,10 @@ const REGISTRY: Record = { 'components-feedback-toast/destructive': { id: 'components-feedback-toast/destructive', meta: { - title: "Destructive", - description: "", + title: "Error Variant", + description: "The 'error' toast variant. 'destructive' is a button variant, not a toast variant.", category: 'components-feedback-toast', + tags: ["toast", "variant", "error"], }, schema: components_feedback_toast_destructive, }, @@ -1793,18 +1796,20 @@ const REGISTRY: Record = { 'components-feedback-toast/toast-with-action': { id: 'components-feedback-toast/toast-with-action', meta: { - title: "Toast With Action", - description: "", + title: "Info Variant", + description: "The 'info' toast variant, used here for an update notice.", category: 'components-feedback-toast', + tags: ["toast", "variant", "info"], }, schema: components_feedback_toast_toast_with_action, }, 'components-feedback-toast/toast-with-undo': { id: 'components-feedback-toast/toast-with-undo', meta: { - title: "Toast With Undo", - description: "", + title: "Warning Variant", + description: "The 'warning' toast variant, used here for a deletion notice.", category: 'components-feedback-toast', + tags: ["toast", "variant", "warning"], }, schema: components_feedback_toast_toast_with_undo, }, diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/basic-sonner-toast.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/basic-sonner-toast.json index 910c93c0b1..10a40eea45 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/basic-sonner-toast.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/basic-sonner-toast.json @@ -1,8 +1,5 @@ { - "type": "button", - "label": "Show Sonner Toast", - "onClick": { - "action": "sonner", - "message": "Event has been created" - } + "type": "sonner", + "message": "Event has been created", + "buttonLabel": "Show Sonner Toast" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/error.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/error.json index 7ed636d87e..63a355c0a9 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/error.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/error.json @@ -1,10 +1,7 @@ { - "type": "button", - "label": "Error", - "variant": "destructive", - "onClick": { - "action": "sonner", - "type": "error", - "message": "Something went wrong" - } + "type": "sonner", + "variant": "error", + "message": "Something went wrong", + "buttonLabel": "Error", + "buttonVariant": "destructive" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/info.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/info.json index 1ab137baf8..c115d4aef5 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/info.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/info.json @@ -1,9 +1,6 @@ { - "type": "button", - "label": "Info", - "onClick": { - "action": "sonner", - "type": "info", - "message": "New update available" - } + "type": "sonner", + "variant": "info", + "message": "New update available", + "buttonLabel": "Info" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/promise-based-toast.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/promise-based-toast.json index 9d911be845..cf3ec6b51a 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/promise-based-toast.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/promise-based-toast.json @@ -1,11 +1,7 @@ { - "type": "button", - "label": "Save Changes", - "onClick": { - "action": "sonner", - "type": "promise", - "loading": "Saving changes...", - "success": "Changes saved!", - "error": "Failed to save changes" - } + "type": "sonner", + "variant": "success", + "message": "Changes saved!", + "buttonLabel": "Save Changes", + "buttonVariant": "outline" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/success.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/success.json index 556e85c813..57f55da277 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/success.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/success.json @@ -1,9 +1,6 @@ { - "type": "button", - "label": "Success", - "onClick": { - "action": "sonner", - "type": "success", - "message": "Operation completed successfully" - } + "type": "sonner", + "variant": "success", + "message": "Operation completed successfully", + "buttonLabel": "Success" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/toast-with-action.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/toast-with-action.json index b550a43c02..b37e3d88f6 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/toast-with-action.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/toast-with-action.json @@ -1,12 +1,7 @@ { - "type": "button", - "label": "Show with Action", - "onClick": { - "action": { - "label": "View", - "onClick": "viewFile" - }, - "message": "File uploaded", - "description": "Your file has been uploaded successfully" - } + "type": "sonner", + "variant": "success", + "message": "File uploaded", + "description": "Your file has been uploaded successfully", + "buttonLabel": "Upload File" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-sonner/warning.json b/examples/schema-catalog/src/schemas/components-feedback-sonner/warning.json index ce5a7529a5..971133ccf3 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-sonner/warning.json +++ b/examples/schema-catalog/src/schemas/components-feedback-sonner/warning.json @@ -1,9 +1,6 @@ { - "type": "button", - "label": "Warning", - "onClick": { - "action": "sonner", - "type": "warning", - "message": "Please review your input" - } + "type": "sonner", + "variant": "warning", + "message": "Please review your input", + "buttonLabel": "Warning" } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/basic-toast.json b/examples/schema-catalog/src/schemas/components-feedback-toast/basic-toast.json index 733b8dbb05..48dd40a2ea 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/basic-toast.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/basic-toast.json @@ -1,9 +1,5 @@ { - "type": "button", - "label": "Show Toast", - "onClick": { - "action": "toast", - "title": "Success", - "description": "Your changes have been saved." - } + "type": "toast", + "title": "Success", + "description": "Your changes have been saved." } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/default.json b/examples/schema-catalog/src/schemas/components-feedback-toast/default.json index 80fd896e82..a2c694b8d2 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/default.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/default.json @@ -1,9 +1,6 @@ { - "type": "button", - "label": "Default Toast", - "onClick": { - "action": "toast", - "title": "Notification", - "description": "This is a default toast message." - } + "type": "toast", + "variant": "default", + "title": "Notification", + "description": "This is a default toast message." } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/destructive.json b/examples/schema-catalog/src/schemas/components-feedback-toast/destructive.json index 8a7145bec0..b4a37b1c14 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/destructive.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/destructive.json @@ -1,11 +1,6 @@ { - "type": "button", - "label": "Destructive Toast", - "variant": "destructive", - "onClick": { - "action": "toast", - "variant": "error", - "title": "Error", - "description": "Something went wrong." - } + "type": "toast", + "variant": "error", + "title": "Error", + "description": "Something went wrong." } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/error-toast.json b/examples/schema-catalog/src/schemas/components-feedback-toast/error-toast.json index 36ede0b2c6..433188c257 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/error-toast.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/error-toast.json @@ -1,11 +1,6 @@ { - "type": "button", - "label": "Submit", - "variant": "destructive", - "onClick": { - "action": "toast", - "variant": "error", - "title": "Submission Failed", - "description": "Please check your input and try again." - } + "type": "toast", + "variant": "error", + "title": "Submission Failed", + "description": "Please check your input and try again." } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/success-toast.json b/examples/schema-catalog/src/schemas/components-feedback-toast/success-toast.json index 8c9da4200e..9309c6a395 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/success-toast.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/success-toast.json @@ -1,10 +1,7 @@ { - "type": "button", - "label": "Save", - "onClick": { - "action": "toast", - "title": "Saved Successfully", - "description": "Your changes have been saved.", - "duration": 3000 - } + "type": "toast", + "variant": "success", + "title": "Saved Successfully", + "description": "Your changes have been saved.", + "duration": 3000 } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-action.json b/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-action.json index 7b1a18313c..c312784c33 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-action.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-action.json @@ -1,10 +1,6 @@ { - "type": "button", - "label": "Show Toast with Action", - "onClick": { - "action": "toast", - "title": "Update Available", - "description": "A new version is available.", - "actionLabel": "Update Now" - } + "type": "toast", + "variant": "info", + "title": "Update Available", + "description": "A new version is available." } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-undo.json b/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-undo.json index 81ecfd93fc..0a6df6ac3c 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-undo.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toast/toast-with-undo.json @@ -1,10 +1,6 @@ { - "type": "button", - "label": "Delete Item", - "onClick": { - "action": "toast", - "title": "Item Deleted", - "description": "The item has been removed.", - "actionLabel": "Undo" - } + "type": "toast", + "variant": "warning", + "title": "Item Deleted", + "description": "The item has been removed." } diff --git a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts index b1b2a60cf5..3aa9e7784b 100644 --- a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts +++ b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts @@ -43,15 +43,26 @@ * `direction`. The probe must be structural — asserting `.success` here * would assert nothing at all. * - * ## The one key on these fixtures that is CORRECT and must stay + * ## SUPERSEDED by objectui#6250 — where the toast fixtures went * - * Both toast fixtures ALSO carry a **top-level** `"variant": "destructive"`. - * That one is a genuine `ButtonSchema` member (`form.d.ts:30`, - * `zod/form.zod.js:153`) — the demo's button really is destructive-styled. Only - * the occurrence nested inside the `onClick` toast payload was invented. A - * blanket find-and-replace over `destructive` in these two files breaks two - * working buttons; the first `describe` block below exists to make that - * mistake turn this file red. + * The two toast fixtures used to be `{ "type": "button", …, "onClick": { action: + * 'toast', … } }`, and the block that used to head this file asserted that the + * **top-level** `"variant": "destructive"` — a genuine `ButtonSchema` member — + * survived any correction to the nested payload. + * + * #6250 removed the envelope that fact was about. `ButtonSchema.onClick` is + * `z.function()`, so all fourteen `components-feedback-toast/*` and + * `components-feedback-sonner/*` fixtures were a RED `safeParse` on the + * ENVELOPE, and nothing anywhere read a handler key as an action object — the + * demos are now the registered `type: 'toast'` / `type: 'sonner'` nodes their + * own renderers execute. There is no button schema left to carry a button + * variant, so that block is REPLACED rather than reworded: what #6157 actually + * established — that the toast variant VALUE is a declared `ToastSchema` + * member, with `destructive` refused — is carried forward below against the + * top-level `variant` those nodes now declare, counter-probe and all. + * + * `toast-demo-dispatch-6250.test.tsx` carries the other half: that clicking + * each corrected demo raises a real toast, which no parse can see. * * ## What this file deliberately does not assert * @@ -69,7 +80,7 @@ import { RadioGroupSchema, ToastSchema, } from '@object-ui/types/zod'; -import { getExample } from '../src/index.js'; +import { allExamples, getExample } from '../src/index.js'; type Json = Record; @@ -96,45 +107,53 @@ const TOAST_FIXTURES = [ 'components-feedback-toast/error-toast', ] as const; -describe('toast fixtures: the top-level button variant is CORRECT and stays (objectui#6157 rider)', () => { - const buttonVariants = enumOptionsOf(ButtonSchema.shape.variant); - - it('ButtonSchema really does declare `destructive` — the control for this whole block', () => { - expect(buttonVariants).toContain('destructive'); +describe('toast fixtures: the registered spelling, not an action object off `onClick` (objectui#6250)', () => { + it.each(TOAST_FIXTURES)('%s is a `toast` node that parses green under ToastSchema', (id) => { + const fixture = schemaOf(id); + expect(fixture.type).toBe('toast'); + expect(fixture).not.toHaveProperty('onClick'); + const result = ToastSchema.safeParse(fixture); + expect(result.error?.issues ?? []).toEqual([]); + expect(result.success).toBe(true); }); - it.each(TOAST_FIXTURES)('%s keeps a top-level destructive button', (id) => { - const fixture = schemaOf(id); - expect(fixture.type).toBe('button'); - expect(fixture.variant).toBe('destructive'); - expect(buttonVariants).toContain(fixture.variant as string); + it('counter-probe: the retired envelope is still RED under ButtonSchema, so the block above bites', () => { + const retired = { + type: 'button', + label: 'Destructive Toast', + variant: 'destructive', + onClick: { + action: 'toast', + variant: 'error', + title: 'Error', + description: 'Something went wrong.', + }, + }; + const result = ButtonSchema.safeParse(retired); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(['onClick']); + expect(result.error?.issues[0]?.message).toContain('expected function, received object'); }); }); -describe('toast fixtures: the nested onClick payload uses a declared toast variant', () => { - /** - * The payload is `{ action: 'toast', ... }` hung off `onClick`. The shipped - * surface declares `ButtonSchema.onClick` as a FUNCTION, so this action-object - * idiom has no declared type of its own — `ToastSchema` is the nearest - * governing declaration, and it is unambiguous here: the payload's other keys - * (`title`, `description`, `duration`) are exactly ToastSchema's. - */ - const asToast = (fixture: Json): Json => { - const { action, ...rest } = fixture.onClick as Json; - expect(action).toBe('toast'); - return { type: 'toast', ...rest }; - }; - - it.each(TOAST_FIXTURES)('%s payload parses green under ToastSchema', (id) => { - const result = ToastSchema.safeParse(asToast(schemaOf(id))); - expect(result.error?.issues ?? []).toEqual([]); - expect(result.success).toBe(true); +describe('toast fixtures: the variant value is a declared member (objectui#6157, carried forward)', () => { + const toastVariants = enumOptionsOf(ToastSchema.shape.variant); + + it('ToastSchema declares `error` and refuses `destructive` — the control for this block', () => { + expect(toastVariants).toContain('error'); + expect(toastVariants).not.toContain('destructive'); + }); + + it.each(TOAST_FIXTURES)('%s declares a variant ToastSchema knows', (id) => { + const fixture = schemaOf(id); + expect(fixture.variant).toBe('error'); + expect(toastVariants).toContain(fixture.variant as string); }); it.each(TOAST_FIXTURES)( '%s counter-probe: the pre-#6157 value is still refused, so the assertion above bites', (id) => { - const payload = { ...asToast(schemaOf(id)), variant: 'destructive' }; + const payload = { ...schemaOf(id), variant: 'destructive' }; const result = ToastSchema.safeParse(payload); expect(result.success).toBe(false); expect(result.error?.issues[0]?.path).toEqual(['variant']); @@ -199,3 +218,57 @@ describe('radio-group fixtures: the layout key is the declared one', () => { expect(RadioGroupSchema.safeParse(fixture).success).toBe(true); }); }); + +/** + * objectui#6250, generalized past the two pages that reported it. + * + * The card's shape is "an action object hung off a handler key", and a grep for + * one literal (`"action": "toast"`) under-counts it by construction — the sonner + * half spells the same shape `{ action: 'sonner', … }`, and one fixture spells + * it `{ action: { label, onClick } }`. So the sweep is over the SHAPE: every key + * that reads as a handler slot, at every depth, in every entry. + * + * What it deliberately does NOT cover: a handler key holding a STRING. That is + * the handler-EXPRESSION dialect — whether an expression is a supported handler + * form is objectui#6182's open decision, not this card's — and the corpus still + * has exactly one, which is what the positive control below pins. A sweep that + * banned handler keys outright would be answering #6182 by accident. + */ +describe('catalog corpus: no fixture hangs an action object off a handler key (objectui#6250)', () => { + type Handler = { where: string; value: unknown }; + + const isHandlerKey = (key: string) => /^on[A-Z]/.test(key) || key === 'events'; + + function collect(node: unknown, where: string, acc: Handler[] = []): Handler[] { + if (Array.isArray(node)) { + node.forEach((n, i) => collect(n, `${where}[${i}]`, acc)); + return acc; + } + if (!node || typeof node !== 'object') return acc; + for (const [key, value] of Object.entries(node as Record)) { + if (isHandlerKey(key)) acc.push({ where: `${where}.${key}`, value }); + collect(value, `${where}.${key}`, acc); + } + return acc; + } + + const entries = allExamples(); + const handlers = entries.flatMap((e) => collect(e.schema, e.id)); + + it('the walker descends into nested children — positive control', () => { + // A zero result from a walker that never descends is an untested tool, not + // a measurement. This hit is two levels down, inside `children`, and it is + // the handler-EXPRESSION case #6182 owns. + expect(handlers.map((h) => h.where)).toContain( + 'components-feedback-toaster/with-toast-trigger.children[0].onClick', + ); + expect(entries.length).toBeGreaterThan(400); + }); + + it('every handler value in the corpus is a string expression, never an action object', () => { + const objectValued = handlers + .filter((h) => h.value !== null && typeof h.value === 'object') + .map((h) => `${h.where} = ${JSON.stringify(h.value)}`); + expect(objectValued).toEqual([]); + }); +}); diff --git a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx index 6cce6f9ff7..1d05d12765 100644 --- a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx +++ b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx @@ -116,7 +116,12 @@ const MEASURED_TYPES = [ * early reads CLEAN below and has earned nothing. */ const NODE_CENSUS: Readonly> = { - button: { rendered: 140, noElement: 0 }, + // 140 -> 126 with objectui#6250: the fourteen `components-feedback-toast/*` + // and `components-feedback-sonner/*` demos were `type: 'button'` nodes + // carrying an action object on `onClick`, and are now the registered + // `toast` / `sonner` nodes their own renderers execute. Catalog-authored, + // no renderer touched — the case this table's header sanctions. + button: { rendered: 126, noElement: 0 }, input: { rendered: 48, noElement: 0 }, checkbox: { rendered: 12, noElement: 0 }, switch: { rendered: 7, noElement: 0 }, diff --git a/examples/schema-catalog/test/toast-demo-dispatch-6250.test.tsx b/examples/schema-catalog/test/toast-demo-dispatch-6250.test.tsx new file mode 100644 index 0000000000..18fe42532d --- /dev/null +++ b/examples/schema-catalog/test/toast-demo-dispatch-6250.test.tsx @@ -0,0 +1,197 @@ +/** + * 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. + */ + +/** + * objectui#6250 — the fourteen `components-feedback-toast/*` and + * `components-feedback-sonner/*` demos hung an action object off `onClick`: + * + * { "type": "button", "label": "Destructive Toast", "variant": "destructive", + * "onClick": { "action": "toast", "variant": "error", "title": "Error", … } } + * + * That shape is declared nowhere and executed nowhere, and the docs page prints + * `JSON.stringify(schema)` right beside the demo + * (`apps/site/app/components/InteractiveDemo.tsx`), so it is the literal + * copy-paste surface for every reader and every few-shot retriever. + * + * ## The half this file carries + * + * The DECLARED half — `ButtonSchema.onClick` is `z.function()` + * (`zod/form.zod.ts`), so all fourteen were a RED `safeParse` on the ENVELOPE — + * belongs to `component-fixture-declared-keys.test.ts`. This file carries the + * EXECUTED half, which no schema parse can see. + * + * `ActionRunner`'s runnable vocabulary is `script | url | modal | flow | api | + * form | navigation` (`builtinExecutors`, `core/src/actions/ActionRunner.ts`) — + * there is no `toast` and no `sonner` executor, and nothing anywhere reads a + * handler key as an action object. So `{ action: 'toast', … }` had no + * dispatcher on any tree. + * + * ⚠️ It was WORSE than inert, and that is measured below rather than asserted. + * `onClick` is a member of `SDUI_DOM_PASS_THROUGH_KEYS` + * (`core/src/utils/dom-props.ts`), so the action object was forwarded to the + * rendered `