From 528b56d6e7cf62f8d1fdf00a67a6f0569a593a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:04:33 +0000 Subject: [PATCH 1/2] fix(plugin-view): call the view-control callback prop only when it is a function, so an authored event name no longer crashes through SchemaRenderer (objectui#10616) filter-ui, sort-ui and view-switcher declare onChange / onViewChange as a window event NAME (objectui#6124). SchemaRenderer spreads node keys as React props, so the authored string also reached the same-named callback prop, and `onChange?.(next)` threw "onChange is not a function" before the event was dispatched. One shared helper now calls the prop only when it is a function and dispatches the event named on the schema; a host function is still called first. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...ew-handler-event-names-through-renderer.md | 30 +++ packages/plugin-view/src/FilterUI.tsx | 14 +- packages/plugin-view/src/SortUI.tsx | 14 +- packages/plugin-view/src/ViewSwitcher.tsx | 14 +- .../handlerEventNameLiveness.6124.test.tsx | 21 +- ...lerEventNameThroughRenderer-10616.test.tsx | 180 ++++++++++++++++++ .../plugin-view/src/viewHandlerChannels.ts | 54 ++++++ 7 files changed, 294 insertions(+), 33 deletions(-) create mode 100644 .changeset/10616-view-handler-event-names-through-renderer.md create mode 100644 packages/plugin-view/src/__tests__/handlerEventNameThroughRenderer-10616.test.tsx create mode 100644 packages/plugin-view/src/viewHandlerChannels.ts diff --git a/.changeset/10616-view-handler-event-names-through-renderer.md b/.changeset/10616-view-handler-event-names-through-renderer.md new file mode 100644 index 0000000000..71e582c739 --- /dev/null +++ b/.changeset/10616-view-handler-event-names-through-renderer.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-view': patch +--- + +`filter-ui`, `sort-ui` and `view-switcher` no longer crash when an authored +document sets their event-name key (`onChange`, or `onViewChange` on the +switcher). + +These keys hold the NAME of a `CustomEvent` dispatched on `window` +(objectui#6124). `SchemaRenderer` also passes every node key to the component +as a React prop, so the authored string reached the component's callback prop +of the same name. The component called that prop, so the first interaction +threw `TypeError: onChange is not a function` (or `onViewChange`) and the event +was never dispatched. This form rendered through `SchemaRenderer` crashed: + +```json +{ "type": "sort-ui", "fields": [{ "field": "name" }], "onChange": "myapp:sort-changed" } +``` + +The three controls now call the prop only when it is a function, and dispatch +the event named on the schema. The listener below receives `e.detail.sort` +(`e.detail.values` for `filter-ui`, `e.detail.view` for `view-switcher`): + +```js +window.addEventListener('myapp:sort-changed', (e) => e.detail.sort); +``` + +A React host that passes a function through `SchemaRenderer` is unaffected. +Its function is called first with the new value. The authored event, if one is +named, is then dispatched as before. diff --git a/packages/plugin-view/src/FilterUI.tsx b/packages/plugin-view/src/FilterUI.tsx index 3382358641..651baa8e19 100644 --- a/packages/plugin-view/src/FilterUI.tsx +++ b/packages/plugin-view/src/FilterUI.tsx @@ -30,6 +30,7 @@ import { import { cva } from 'class-variance-authority'; import { SlidersHorizontal, X } from 'lucide-react'; import type { FilterUISchema } from '@object-ui/types'; +import { notifyViewHandlerChannels } from './viewHandlerChannels'; export type FilterUIProps = { schema: FilterUISchema; @@ -94,15 +95,10 @@ export const FilterUI: React.FC = ({ }, [schema.values]); const notifyChange = React.useCallback((nextValues: FilterValue) => { - onChange?.(nextValues); - - if (schema.onChange && typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent(schema.onChange, { - detail: { values: nextValues }, - }) - ); - } + // Host function, then the authored event name (objectui#6124). The helper + // calls the prop only when it is a function: through `SchemaRenderer` the + // prop can hold the authored string (objectui#10616). + notifyViewHandlerChannels(onChange, schema.onChange, nextValues, { values: nextValues }); }, [onChange, schema.onChange]); const updateValue = React.useCallback((field: string, value: any) => { diff --git a/packages/plugin-view/src/SortUI.tsx b/packages/plugin-view/src/SortUI.tsx index 2706c44ea6..6daec82fe7 100644 --- a/packages/plugin-view/src/SortUI.tsx +++ b/packages/plugin-view/src/SortUI.tsx @@ -21,6 +21,7 @@ import { cva } from 'class-variance-authority'; import { ArrowUp } from 'lucide-react'; import type { SortItem } from '@object-ui/components'; import type { SortUISchema } from '@object-ui/types'; +import { notifyViewHandlerChannels } from './viewHandlerChannels'; export type SortUIProps = { schema: SortUISchema; @@ -88,15 +89,10 @@ export const SortUI: React.FC = ({ const notifyChange = React.useCallback((nextSort: SortEntry[]) => { setSortState(nextSort); - onChange?.(nextSort); - - if (schema.onChange && typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent(schema.onChange, { - detail: { sort: nextSort }, - }) - ); - } + // Host function, then the authored event name (objectui#6124). The helper + // calls the prop only when it is a function: through `SchemaRenderer` the + // prop can hold the authored string (objectui#10616). + notifyViewHandlerChannels(onChange, schema.onChange, nextSort, { sort: nextSort }); }, [onChange, schema.onChange]); const handleToggle = React.useCallback((field: string) => { diff --git a/packages/plugin-view/src/ViewSwitcher.tsx b/packages/plugin-view/src/ViewSwitcher.tsx index 05ede3aed7..b7551fc9d7 100644 --- a/packages/plugin-view/src/ViewSwitcher.tsx +++ b/packages/plugin-view/src/ViewSwitcher.tsx @@ -23,6 +23,7 @@ import { import { cva } from 'class-variance-authority'; import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; import type { ViewSwitcherSchema, ViewType } from '@object-ui/types'; +import { notifyViewHandlerChannels } from './viewHandlerChannels'; import { Activity, Calendar, @@ -270,15 +271,10 @@ export const ViewSwitcher: React.FC = ({ }, [activeView, schema.activeView, schema.persistPreference, storageKey]); const notifyChange = React.useCallback((nextView: ViewType) => { - onViewChange?.(nextView); - - if (schema.onViewChange && typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent(schema.onViewChange, { - detail: { view: nextView }, - }) - ); - } + // Host function, then the authored event name (objectui#6124). The helper + // calls the prop only when it is a function: through `SchemaRenderer` the + // prop can hold the authored string (objectui#10616). + notifyViewHandlerChannels(onViewChange, schema.onViewChange, nextView, { view: nextView }); }, [onViewChange, schema.onViewChange]); const handleViewChange = React.useCallback((nextView: ViewType) => { diff --git a/packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsx b/packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsx index 8f3368878d..7beeea570e 100644 --- a/packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsx +++ b/packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsx @@ -19,18 +19,27 @@ * dispatches). Three rows are the second kind, and the scope extension that * swept them in for retirement would have DELETED WORKING BEHAVIOUR: * - * - `ViewSwitcherSchema.onViewChange` — `ViewSwitcher.tsx:249-255` - * - `FilterUISchema.onChange` — `FilterUI.tsx:99-105` - * - `SortUISchema.onChange` — `SortUI.tsx:93-99` + * - `ViewSwitcherSchema.onViewChange` — `ViewSwitcher`'s `notifyChange` + * - `FilterUISchema.onChange` — `FilterUI`'s `notifyChange` + * - `SortUISchema.onChange` — `SortUI`'s `notifyChange` * - * each `window.dispatchEvent(new CustomEvent(schema., { detail }))`, i.e. - * the AUTHORED STRING IS THE EVENT NAME. Note the dual channel that produced - * the mislabel: `onViewChange?.(next)` right above it is the REACT PROP (a + * each hands `schema.` to `notifyViewHandlerChannels` + * (`viewHandlerChannels.ts`), which runs + * `window.dispatchEvent(new CustomEvent(schema., { detail }))`, i.e. the + * AUTHORED STRING IS THE EVENT NAME. Note the dual channel that produced the + * mislabel: the same helper also calls the REACT PROP `onViewChange` (a * function a host passes), while `schema.onViewChange` is the authored string. * Same name, two channels — which is exactly what a type-shaped census cannot * see, and why the `.describe()` text on those three rows now says "event name" * rather than "callback". * + * ⚠️ This file renders the components DIRECTLY and mocks `SchemaRenderer`, so + * the callback prop is always `undefined` here. Through the real renderer the + * authored string also lands in that prop, and calling it threw + * `TypeError: onChange is not a function` while every pin below stayed green + * (objectui#10616). That entry is pinned in + * `handlerEventNameThroughRenderer-10616.test.tsx`. + * * ## Why BOTH halves, and why the declared half is not a `safeParse` * * A retirement of one of these keys has two independent failure surfaces, so diff --git a/packages/plugin-view/src/__tests__/handlerEventNameThroughRenderer-10616.test.tsx b/packages/plugin-view/src/__tests__/handlerEventNameThroughRenderer-10616.test.tsx new file mode 100644 index 0000000000..0eefa4106f --- /dev/null +++ b/packages/plugin-view/src/__tests__/handlerEventNameThroughRenderer-10616.test.tsx @@ -0,0 +1,180 @@ +/** + * 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#10616 — the three view event-name keys work through the REAL + * `SchemaRenderer`, which is the path an authored document takes. + * + * ## The defect this pins + * + * objectui#6124 declared `FilterUISchema.onChange`, `SortUISchema.onChange` + * and `ViewSwitcherSchema.onViewChange` as EVENT NAMES: the authored string is + * the name of a `CustomEvent` dispatched on `window`. But `SchemaRenderer` + * spreads every non-metadata node key as a React prop, so the same string also + * lands in the component's same-named CALLBACK prop. Each control called that + * prop before dispatching, so the first interaction threw + * `TypeError: onChange is not a function` (`onViewChange` on the switcher) and + * the window event after it never fired. + * + * ## Why this file does not mock `SchemaRenderer` + * + * The objectui#6124 pins (`handlerEventNameLiveness.6124.test.tsx`) render the + * components DIRECTLY and mock `SchemaRenderer`, so the callback prop is always + * `undefined` there and they stayed green over this defect. Those pins still + * own what they own — the keys are declared, and the directly rendered control + * dispatches — and this file owns the other entry: a registered node rendered + * through the real renderer and registry, with and without a host function. + * + * ## What each leg asserts + * + * - AUTHORED string, no host prop: the documented event fires once with the + * documented `detail`, and nothing is reported (no `TypeError`). + * - CONTROL — a host FUNCTION passed through `SchemaRenderer` next to the same + * authored string: the function is called once with the new value AND the + * event still fires. It was green before the fix too; it is here so the fix + * cannot pass by dropping the host channel. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SchemaRenderer } from '@object-ui/react'; +// Module scope, not a hook: this import IS the registration of +// `filter-ui`, `sort-ui` and `view-switcher` in the real registry. +import '../index'; + +interface Reading { + /** Every error React or the runtime reported while the interaction ran. */ + reported: string[]; + /** The `detail` of every `eventName` event dispatched on `window`. */ + details: D[]; +} + +/** + * Run `act` and record what it reported and dispatched. + * + * Errors are read from THREE places because React 19 does not rethrow an + * error thrown inside an event handler out of `fireEvent` — it REPORTS it + * (window `error` event and `console.error`). A bare `expect(...).toThrow()` + * would read a crashing control as green. + */ +function interact(eventName: string, act: () => void): Reading { + const reported: string[] = []; + const details: D[] = []; + const onEvent = (e: Event) => details.push((e as CustomEvent).detail); + const onError = (e: Event) => { + reported.push(String((e as ErrorEvent).error ?? e)); + e.preventDefault(); + }; + const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + reported.push(args.map((a) => String(a)).join(' ')); + }); + window.addEventListener(eventName, onEvent); + window.addEventListener('error', onError); + try { + act(); + } catch (err) { + reported.push(String(err)); + } finally { + window.removeEventListener(eventName, onEvent); + window.removeEventListener('error', onError); + spy.mockRestore(); + } + return { reported, details }; +} + +describe('filter-ui: an authored onChange event name through SchemaRenderer (objectui#10616)', () => { + const node = { + type: 'filter-ui', + layout: 'inline', + filters: [{ field: 'qty', label: 'Qty', type: 'number' }], + onChange: 'zz10616:filter', + }; + const typeSeven = (container: HTMLElement) => { + const input = container.querySelector('input[type="number"]') as HTMLInputElement | null; + expect(input, 'the registered filter-ui rendered no number input').toBeTruthy(); + fireEvent.change(input!, { target: { value: '7' } }); + }; + + it('dispatches the named window event with detail { values } and reports nothing', () => { + const { container } = render(); + const { reported, details } = interact<{ values: Record }>( + 'zz10616:filter', + () => typeSeven(container), + ); + expect(reported).toEqual([]); + expect(details).toHaveLength(1); + expect(details[0]).toEqual({ values: { qty: 7 } }); + }); + + it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => { + const host = vi.fn(); + const { container } = render(); + const { reported, details } = interact<{ values: Record }>( + 'zz10616:filter', + () => typeSeven(container), + ); + expect(reported).toEqual([]); + expect(host).toHaveBeenCalledTimes(1); + expect(host).toHaveBeenCalledWith({ qty: 7 }); + expect(details).toEqual([{ values: { qty: 7 } }]); + }); +}); + +describe('sort-ui: an authored onChange event name through SchemaRenderer (objectui#10616)', () => { + const node = { + type: 'sort-ui', + variant: 'buttons', + fields: [{ field: 'name', label: 'Name' }], + onChange: 'zz10616:sort', + }; + const clickName = () => fireEvent.click(screen.getByRole('button', { name: /name/i })); + + it('dispatches the named window event with detail { sort } and reports nothing', () => { + render(); + const { reported, details } = interact<{ sort: unknown }>('zz10616:sort', clickName); + expect(reported).toEqual([]); + expect(details).toEqual([{ sort: [{ field: 'name', direction: 'asc' }] }]); + }); + + it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => { + const host = vi.fn(); + render(); + const { reported, details } = interact<{ sort: unknown }>('zz10616:sort', clickName); + expect(reported).toEqual([]); + expect(host).toHaveBeenCalledTimes(1); + expect(host).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]); + expect(details).toEqual([{ sort: [{ field: 'name', direction: 'asc' }] }]); + }); +}); + +describe('view-switcher: an authored onViewChange event name through SchemaRenderer (objectui#10616)', () => { + const node = { + type: 'view-switcher', + variant: 'buttons', + views: [{ type: 'list' }, { type: 'grid' }], + onViewChange: 'zz10616:view', + }; + const clickGrid = () => fireEvent.click(screen.getByRole('button', { name: /grid/i })); + + it('dispatches the named window event with detail { view } and reports nothing', () => { + render(); + const { reported, details } = interact<{ view: string }>('zz10616:view', clickGrid); + expect(reported).toEqual([]); + expect(details).toEqual([{ view: 'grid' }]); + }); + + it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => { + const host = vi.fn(); + render(); + const { reported, details } = interact<{ view: string }>('zz10616:view', clickGrid); + expect(reported).toEqual([]); + expect(host).toHaveBeenCalledTimes(1); + expect(host).toHaveBeenCalledWith('grid'); + expect(details).toEqual([{ view: 'grid' }]); + }); +}); diff --git a/packages/plugin-view/src/viewHandlerChannels.ts b/packages/plugin-view/src/viewHandlerChannels.ts new file mode 100644 index 0000000000..ef1dc43122 --- /dev/null +++ b/packages/plugin-view/src/viewHandlerChannels.ts @@ -0,0 +1,54 @@ +/** + * 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. + */ + +/** + * Deliver a change of `filter-ui`, `sort-ui` or `view-switcher` on both of its + * channels. The three controls call this one function, so they cannot drift + * apart (objectui#10616). + * + * ## The two channels + * + * 1. HOST: the React prop `onChange` / `onViewChange`, holding a FUNCTION a + * host passes. It is called with the new value. + * 2. AUTHORED: the schema key of the same name, holding an EVENT NAME + * (objectui#6124). It is dispatched on `window` as a `CustomEvent` whose + * `detail` is the documented shape: `{ values }`, `{ sort }` or `{ view }`. + * + * ## Why the prop is called only when it is a function + * + * `SchemaRenderer` spreads every non-metadata node key as a React prop, so + * when no host supplies one, the authored event name also lands in the + * same-named callback prop. The controls used to call that prop with `?.()`, + * which does not guard a string: the first interaction threw + * `TypeError: onChange is not a function` and the window event after it never + * fired, so the declared, documented authoring form crashed through the + * renderer. A string in the prop slot is only the spread copy of the authored + * key, not a second channel: the event name is read from `schema`, the one + * place the contract declares it, and the string is never called. + * + * ## When a host function and an authored name are both present + * + * Neither replaces the other. A host function passed through `SchemaRenderer` + * takes the prop slot (its own props are spread last), and the authored name + * is still on `schema`, so both channels deliver, in the order these controls + * always used: the host function first, then the window event. + */ +export function notifyViewHandlerChannels( + hostCallback: ((value: T) => void) | string | undefined, + eventName: string | undefined, + value: T, + detail: Record, +): void { + if (typeof hostCallback === 'function') { + hostCallback(value); + } + + if (eventName && typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(eventName, { detail })); + } +} From 19efdc426f5d322d7119b5e17f65892804dcad9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:34:43 +0000 Subject: [PATCH 2/2] docs(changeset): SchemaRenderer strips metadata keys before its spread, so the 10616 changeset says it passes these keys through, not every node key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10616-view-handler-event-names-through-renderer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/10616-view-handler-event-names-through-renderer.md b/.changeset/10616-view-handler-event-names-through-renderer.md index 71e582c739..1d3c6d2d14 100644 --- a/.changeset/10616-view-handler-event-names-through-renderer.md +++ b/.changeset/10616-view-handler-event-names-through-renderer.md @@ -7,8 +7,8 @@ document sets their event-name key (`onChange`, or `onViewChange` on the switcher). These keys hold the NAME of a `CustomEvent` dispatched on `window` -(objectui#6124). `SchemaRenderer` also passes every node key to the component -as a React prop, so the authored string reached the component's callback prop +(objectui#6124). `SchemaRenderer` also passes these keys through to the component +as React props, so the authored string reached the component's callback prop of the same name. The component called that prop, so the first interaction threw `TypeError: onChange is not a function` (or `onViewChange`) and the event was never dispatched. This form rendered through `SchemaRenderer` crashed: