From b0deae4efdde4d9c9c188113ca1eee835967d0e3 Mon Sep 17 00:00:00 2001 From: os-support-ai Date: Wed, 26 Aug 2026 07:42:58 +0000 Subject: [PATCH] fix(examples,docs): drop the `provider` key the toaster surface never declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ToasterSchema` declares exactly `type`, `position` and `limit` in both faces (`packages/types/src/feedback.ts`, `packages/types/src/zod/feedback.zod.ts`), and `renderers/feedback/toaster.tsx` reads exactly `schema.position` and `schema.limit` before mounting sonner unconditionally. `provider` was declared nowhere and read nowhere, so the page's "ObjectUI supports two toast providers" claim was false on the tree that shipped it and the two provider demos rendered byte-identically. Four fixtures carried the key, not the two originally reported: both nested toaster nodes had it as well. Two successive demo-shaped censuses each caught one of the two nested hits, which is the same blindness the corpus sweep beside this one already records — a sweep written for one shape misses another in the same file. `default-provider.json` and `sonner-provider.json` are deleted rather than re-pointed: stripped of `provider` each is exactly `{"type":"toaster"}`, which is byte-identical to the `default-toaster.json` the page already renders under Basic Usage, and the only surfaces that do run — `position` and `limit` — are already demonstrated by `custom-position-limit.json`. The new corpus sweep is structural and depth-first over every `toaster` node, because `BaseSchema` is `.passthrough()`: zod both accepts and preserves the undeclared key, so neither `.success` nor round-trip equality can serve as the probe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- content/docs/components/feedback/toaster.mdx | 17 +-- examples/schema-catalog/src/index.ts | 20 ---- .../custom-position-limit.json | 1 - .../default-provider.json | 4 - .../sonner-provider.json | 4 - .../with-toast-trigger.json | 3 +- .../component-fixture-declared-keys.test.ts | 100 ++++++++++++++++++ 7 files changed, 103 insertions(+), 46 deletions(-) delete mode 100644 examples/schema-catalog/src/schemas/components-feedback-toaster/default-provider.json delete mode 100644 examples/schema-catalog/src/schemas/components-feedback-toaster/sonner-provider.json diff --git a/content/docs/components/feedback/toaster.mdx b/content/docs/components/feedback/toaster.mdx index 3c7d1d69f9..e4d0b0b298 100644 --- a/content/docs/components/feedback/toaster.mdx +++ b/content/docs/components/feedback/toaster.mdx @@ -3,8 +3,6 @@ title: "Toaster" description: "Container for displaying toast notifications" --- -import { DemoGrid } from '@/app/components/ComponentDemo'; - The Toaster component provides a container for toast notifications. It should be placed once at the root level of your application to enable toast functionality. ## Basic Usage @@ -13,15 +11,6 @@ The Toaster component is typically added to your app's layout and doesn't requir -## Providers - -ObjectUI supports two toast providers: - - - - - - ## Schema ```plaintext @@ -29,7 +18,6 @@ interface ToasterSchema { type: 'toaster'; // Configuration - provider?: 'default' | 'sonner'; // Toast provider (default: 'default') position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; // Toast position limit?: number; // Maximum number of toasts (default: 5) @@ -57,7 +45,7 @@ interface ToasterSchema { { type: 'page', title: 'Dashboard', body: [...] }, // Toaster at root level - { type: 'toaster', provider: 'sonner' } + { type: 'toaster' } ] } ``` @@ -75,7 +63,6 @@ The Toaster component works in conjunction with Toast components to display noti ## Notes - Place the Toaster component once in your app's root layout -- The `default` provider uses the standard Shadcn/UI toast system -- The `sonner` provider uses Sonner for more advanced toast features +- ObjectUI ships a single toaster implementation, backed by Sonner - Toast notifications are triggered separately using toast functions or Toast components - The Toaster manages the positioning and lifecycle of all toasts diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index b62c0857c8..36c05203bd 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -163,9 +163,7 @@ import components_feedback_toast_success_toast from './schemas/components-feedba import components_feedback_toast_toast_with_action from './schemas/components-feedback-toast/toast-with-action.json' with { type: 'json' }; import components_feedback_toast_toast_with_undo from './schemas/components-feedback-toast/toast-with-undo.json' with { type: 'json' }; import components_feedback_toaster_custom_position_limit from './schemas/components-feedback-toaster/custom-position-limit.json' with { type: 'json' }; -import components_feedback_toaster_default_provider from './schemas/components-feedback-toaster/default-provider.json' with { type: 'json' }; import components_feedback_toaster_default_toaster from './schemas/components-feedback-toaster/default-toaster.json' with { type: 'json' }; -import components_feedback_toaster_sonner_provider from './schemas/components-feedback-toaster/sonner-provider.json' with { type: 'json' }; import components_feedback_toaster_with_toast_trigger from './schemas/components-feedback-toaster/with-toast-trigger.json' with { type: 'json' }; import components_form_button_button_sizes from './schemas/components-form-button/button-sizes.json' with { type: 'json' }; import components_form_button_button_with_icon from './schemas/components-form-button/button-with-icon.json' with { type: 'json' }; @@ -1822,15 +1820,6 @@ const REGISTRY: Record = { }, schema: components_feedback_toaster_custom_position_limit, }, - 'components-feedback-toaster/default-provider': { - id: 'components-feedback-toaster/default-provider', - meta: { - title: "Default Provider", - description: "", - category: 'components-feedback-toaster', - }, - schema: components_feedback_toaster_default_provider, - }, 'components-feedback-toaster/default-toaster': { id: 'components-feedback-toaster/default-toaster', meta: { @@ -1840,15 +1829,6 @@ const REGISTRY: Record = { }, schema: components_feedback_toaster_default_toaster, }, - 'components-feedback-toaster/sonner-provider': { - id: 'components-feedback-toaster/sonner-provider', - meta: { - title: "Sonner Provider", - description: "", - category: 'components-feedback-toaster', - }, - schema: components_feedback_toaster_sonner_provider, - }, 'components-feedback-toaster/with-toast-trigger': { id: 'components-feedback-toaster/with-toast-trigger', meta: { diff --git a/examples/schema-catalog/src/schemas/components-feedback-toaster/custom-position-limit.json b/examples/schema-catalog/src/schemas/components-feedback-toaster/custom-position-limit.json index f436290a3a..ec2650ab76 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toaster/custom-position-limit.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toaster/custom-position-limit.json @@ -3,7 +3,6 @@ "children": [ { "type": "toaster", - "provider": "sonner", "position": "top-right", "limit": 3 } diff --git a/examples/schema-catalog/src/schemas/components-feedback-toaster/default-provider.json b/examples/schema-catalog/src/schemas/components-feedback-toaster/default-provider.json deleted file mode 100644 index a353c05ebd..0000000000 --- a/examples/schema-catalog/src/schemas/components-feedback-toaster/default-provider.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "toaster", - "provider": "default" -} diff --git a/examples/schema-catalog/src/schemas/components-feedback-toaster/sonner-provider.json b/examples/schema-catalog/src/schemas/components-feedback-toaster/sonner-provider.json deleted file mode 100644 index 6507d58272..0000000000 --- a/examples/schema-catalog/src/schemas/components-feedback-toaster/sonner-provider.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "toaster", - "provider": "sonner" -} diff --git a/examples/schema-catalog/src/schemas/components-feedback-toaster/with-toast-trigger.json b/examples/schema-catalog/src/schemas/components-feedback-toaster/with-toast-trigger.json index 4cdee24bb0..35f50078f6 100644 --- a/examples/schema-catalog/src/schemas/components-feedback-toaster/with-toast-trigger.json +++ b/examples/schema-catalog/src/schemas/components-feedback-toaster/with-toast-trigger.json @@ -8,8 +8,7 @@ "onClick": "toast(\"Hello from ObjectUI!\")" }, { - "type": "toaster", - "provider": "sonner" + "type": "toaster" } ] } 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 3aa9e7784b..62106968a7 100644 --- a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts +++ b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts @@ -79,6 +79,7 @@ import { CommandItemSchema, RadioGroupSchema, ToastSchema, + ToasterSchema, } from '@object-ui/types/zod'; import { allExamples, getExample } from '../src/index.js'; @@ -272,3 +273,102 @@ describe('catalog corpus: no fixture hangs an action object off a handler key (o expect(objectValued).toEqual([]); }); }); + + +/** + * objectui#6494 — the same charter as #6250 ("docs teach only what runs"), one + * component over and one shape across. + * + * `provider` was authored by four toaster fixtures and taught by five sites on + * `components/feedback/toaster.mdx`, and `ToasterSchema` declares no such key. + * The renderer (`renderers/feedback/toaster.tsx`) reads exactly `position` and + * `limit` and mounts sonner unconditionally, so the page's "ObjectUI supports + * two toast providers" claim was false on the tree that shipped it: the two + * provider demos rendered byte-identically. + * + * ## Why this sweep is over toaster NODES and not over the two demo files + * + * The card was filed naming two fixtures. A premise re-verification on merged + * `main` corrected it to three — by SWAPPING one nested hit for another rather + * than adding it. The tree actually carried FOUR, because BOTH nested nodes had + * the key: `custom-position-limit.children[0]` and + * `with-toast-trigger.children[1]`. Two successive demo-shaped censuses each + * saw one of the two and reported a complete face. + * + * That is the lesson the block above already wrote down — a sweep written for + * one shape is blind to another in the same file — and the hit it missed sat in + * the very file that block pins as its own positive control. So this block is + * structural and depth-first over the corpus: every node whose `type` is + * `toaster`, wherever it sits, must carry only keys the shipped `ToasterSchema` + * declares. A per-file or root-only assertion would re-inherit the exact + * blindness that produced the undercount twice. + * + * ## Why `.success` is not the probe here, and neither is round-trip equality + * + * This is the file's class 3 (declared-elsewhere, refused by neither), and it is + * strictly worse than the radio-group case. `BaseSchema` is `.passthrough()`, so + * zod does not merely ACCEPT `provider` — it PRESERVES it. Measured on the built + * dist: `ToasterSchema.safeParse({ type: 'toaster', provider: 'sonner' })` + * returns success with `provider` still on `.data`. So the class-2 probe — + * round-trip equality, which is what catches the stripped `shortcut` key above — + * is blind here too. Only a structural key-subset assertion bites, and the + * counter-probe below pins both blindnesses so this block cannot be "simplified" + * into a parse. + */ +describe('catalog corpus: no toaster node carries a key ToasterSchema does not declare (objectui#6494)', () => { + type ToasterNode = { where: string; node: Json }; + + function collectToasters(node: unknown, where: string, acc: ToasterNode[] = []): ToasterNode[] { + if (Array.isArray(node)) { + node.forEach((n, i) => collectToasters(n, `${where}[${i}]`, acc)); + return acc; + } + if (!node || typeof node !== 'object') return acc; + const record = node as Json; + if (record.type === 'toaster') acc.push({ where, node: record }); + for (const [key, value] of Object.entries(record)) { + collectToasters(value, `${where}.${key}`, acc); + } + return acc; + } + + const declaredKeys = Object.keys( + (ToasterSchema as unknown as { shape: Record }).shape, + ); + const toasters = allExamples().flatMap((e) => collectToasters(e.schema, e.id)); + + it('`position` and `limit` are declared and `provider` is not — the control for this block', () => { + expect(declaredKeys).toContain('position'); + expect(declaredKeys).toContain('limit'); + expect(declaredKeys).not.toContain('provider'); + }); + + it('counter-probe: passthrough ACCEPTS and PRESERVES `provider`, so no parse can be the probe', () => { + const authored = { type: 'toaster', provider: 'sonner' }; + const result = ToasterSchema.safeParse(authored); + expect(result.success).toBe(true); + // ...and unlike the stripped `shortcut` key above, round-trip equality holds. + expect(result.data).toEqual(authored); + }); + + it('the walker reaches NESTED toaster nodes — positive control', () => { + // Both of these sit inside `children`, and both carried `provider` before + // this card. A census that only read root-level nodes would report a clean + // sweep of the two named demos and miss exactly these two. + expect(toasters.map((t) => t.where)).toEqual( + expect.arrayContaining([ + 'components-feedback-toaster/custom-position-limit.children[0]', + 'components-feedback-toaster/with-toast-trigger.children[1]', + ]), + ); + }); + + it('every toaster node in the corpus carries only declared keys', () => { + const undeclared = toasters.flatMap(({ where, node }) => + Object.keys(node) + .filter((key) => !declaredKeys.includes(key)) + .map((key) => `${where}.${key}`), + ); + expect(undeclared).toEqual([]); + }); +});