Skip to content

Commit 3d17a97

Browse files
fix(plugin-view): an authored event-name onChange / onViewChange no longer crashes filter-ui, sort-ui and view-switcher through SchemaRenderer (objectui#10616) (#10646)
Fixes #10616 Clause-②: no Implemented by the `os-dev` run that the `domain:ui` seat 2 dispatched, session `https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN`. Readings were taken at BASE `eca276070`, the `origin/main` commit the worktree was cut from. Every gate below ran at HEAD `528b56d6e`, which is this branch's head when the PR opens. ## What changed `filter-ui`, `sort-ui` and `view-switcher` declare `onChange` / `onViewChange` as a window event NAME (objectui#6124). `SchemaRenderer` passes every non-metadata node key to the component as a React prop, and then spreads the host's own props last. With no host prop, the authored string therefore lands in the component's callback prop of the same name. Each control called that prop with `onChange?.(next)` before dispatching. The first interaction threw `TypeError: onChange is not a function` (`onViewChange` on the switcher), and the window event never fired. - New `packages/plugin-view/src/viewHandlerChannels.ts` holds one helper, `notifyViewHandlerChannels`, and all three controls call it from their `notifyChange`. It calls the prop only when `typeof` says it is a function. Then, if the schema names an event, it dispatches `new CustomEvent(schema.KEY, { detail })`. The event name always comes from `schema`, never from the prop slot. One helper means the three controls cannot drift apart. - In `FilterUI.tsx`, `SortUI.tsx` and `ViewSwitcher.tsx`, `notifyChange` now calls the helper with the documented detail: `{ values }`, `{ sort }` or `{ view }`. The exported prop types, the zod mirror and the registrations do not change. - The spread in `SchemaRenderer` is not touched. - **Order and precedence:** the host function runs first and the window event second, as before. If a host function and an authored name are both present, both are delivered. The host function takes the prop slot because `SchemaRenderer` spreads the host's props last, and the authored name is still on `schema`. - `handlerEventNameLiveness.6124.test.tsx`: only the header comment changed. It had three cross-file line addresses into lines this diff moves. They are now symbol citations, and the header adds a note that the file mocks `SchemaRenderer` plus a pointer to the new pin. No assertion changed. - `.changeset/10616-view-handler-event-names-through-renderer.md`: `'@object-ui/plugin-view': patch`. ## Pins: through the real `SchemaRenderer` and registry, with no mock `packages/plugin-view/src/__tests__/handlerEventNameThroughRenderer-10616.test.tsx` has six tests, two for each control: - **Authored string, no host prop:** the named event fires once with the documented `detail`, and nothing is reported. The test reads the window `error` event, `console.error` and any thrown error, because React 19 reports a handler error rather than rethrowing it out of `fireEvent`. - **CONTROL:** a host function passed through `SchemaRenderer` next to the same string is called once with the new value, and the event still fires. Red on base, green on head. All five legs ran in one locked run at HEAD `528b56d6e`. The base leg checks out the three components at BASE and keeps the pins from HEAD. | leg | result | |---|---| | HEAD: new pin and the objectui#6124 pin | `Tests 14 passed (14)` | | BASE: all three components at BASE | `Tests 3 failed / 11 passed (14)`. Each authored-string leg fails: `reported` holds `TypeError: onChange is not a function` for filter-ui and sort-ui, and `TypeError: onViewChange is not a function` for view-switcher. The three controls and all eight objectui#6124 tests pass. | | ablation: only `FilterUI.tsx` at BASE | `1 failed / 5 passed (6)`: only the filter-ui authored leg fails | | ablation: only `SortUI.tsx` at BASE | `1 failed / 5 passed (6)`: only the sort-ui authored leg fails | | ablation: only `ViewSwitcher.tsx` at BASE | `1 failed / 5 passed (6)`: only the view-switcher authored leg fails | - Before each run, the mutation was checked on disk: `notifyViewHandlerChannels` occurs 0 times and a bare `onChange?.(` or `onViewChange?.(` occurs once. - Each restore ran from an `EXIT INT TERM` trap. It was proven by comparing each file's hash with its HEAD blob and by an empty `git diff HEAD`. - The tests import source through the repo's vitest aliases, so no build sits between a mutation and its run. The objectui#6124 pins stay. They complement the new pins rather than duplicate them: - They own the declared half (`.shape` and the `describe()` text). - They render the components directly, so the callback prop is always `undefined` there. - The new pins own the renderer entry, where the prop holds the authored string. ## Gates (HEAD `528b56d6e`) - `pnpm exec vitest run packages/plugin-view/`: `Test Files 50 passed (50)`, `Tests 483 passed (483)`. - `pnpm --filter @object-ui/plugin-view type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) ran after `turbo run build --filter='@object-ui/plugin-view^...' --concurrency=2`, which reported `Tasks: 15 successful, 15 total`. Result: exit 0. `tsc -p tsconfig.test.json --listFilesOnly` confirms that the test program contains the new pin, the objectui#6124 pin and `viewHandlerChannels.ts`. - ESLint was narrowed to the six touched TS files: `eslint --no-inline-config --format json` reports 6 files, 0 errors and 11 warnings. All 11 warnings sit on lines this diff does not touch (`no-explicit-any`, `set-state-in-effect`), and lint sets no `--max-warnings`. Three facts support the narrowing: - The population comes from `eslint.config.js`: the `files: ['**/*.{ts,tsx}']` block covers all six files, and none is ignored. - The file count is 6, read from the JSON output. - Nothing outside the touched files can change verdict. The config sets no `parserOptions` or `projectService`, so linting is not type-aware. No rule in `eslint-rules/*.js` reads the filesystem (0 hits for `readFileSync`, `existsSync`, `readdirSync` or `statSync`; control: 9 of 9 rule files match `create`). - These gates all exited 0: `check-changeset-presence`, `check:new-line-citations` (`0 new citation(s)`), `check:control-bytes`, `check-changeset-no-major`, `check-changeset-overwrite`, `check-changeset-fixed`, `check-changeset-claims`, `check:handler-key-reads`, `check:unreferenced-sources`, `check:esm-specifiers`, `check:phantom-deps`, `check:self-import`, `check-vi-mock-override-shape`, `check-vi-mock-specifiers`, `check-vi-mock-inherit` and `check-test-path-roots`. - The diff reaches no doc or readme gate: it touches no `content/` or README file. - `check-governed-queue-guard --test` on the seven paths: `NOT GOVERNED`. - `git merge-tree --write-tree HEAD origin/main` at `f99f9cde7`: clean. `origin/main` has not changed `packages/plugin-view` or the objectui#6124 changeset since BASE. ## The pending objectui#6124 changeset `.changeset/6124-view-handler-keys-are-event-names.md` publishes the authored form as a working example. At this change every sentence in it is true: the renderer dispatches `new CustomEvent(schema.onViewChange, …)` and its two siblings, and the `sort-ui` example and its listener now work through `SchemaRenderer`. The file is therefore unchanged: its md5 is `ccd92197c632d2b200478176ac6d1a95` at BASE and at HEAD. `check-changeset-claims` reports that changeset's last paragraph because it names the objectui#6124 test file, which this diff edits. That report is report-only. The edit only touches the header comment and the pin still holds both halves, so the paragraph stays true. ## Acceptance notes - `content/docs/api/schema-reference.md`, `ViewSwitcherSchema` table: the `onViewChange` row reads "Expression or callback invoked on view change." The objectui#6124 `describe()` text refuses exactly that reading. The row was false before this diff and is still false after it. It is outside the claimed file surface, so it is not changed here and is reported to the seat instead. - The docs rows `onChange?: string;` (`filter-ui.mdx`, `sort-ui.mdx`) and `onViewChange?: string;` (`view-switcher.mdx`) are true at this change. The plugin-view README names neither key. - No other key in `packages/plugin-view` or `views.zod.ts` has this shape: `views.zod.ts` carries exactly these three event-name keys. The one other `Event name` describe under `packages/types/src/zod` is `ActionSchema.tracking.event` in `crud.zod.ts`. It is nested under `tracking`, so it is never spread as a prop, and no component calls a same-named prop. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d89c6f commit 3d17a97

7 files changed

Lines changed: 294 additions & 33 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@object-ui/plugin-view': patch
3+
---
4+
5+
`filter-ui`, `sort-ui` and `view-switcher` no longer crash when an authored
6+
document sets their event-name key (`onChange`, or `onViewChange` on the
7+
switcher).
8+
9+
These keys hold the NAME of a `CustomEvent` dispatched on `window`
10+
(objectui#6124). `SchemaRenderer` also passes these keys through to the component
11+
as React props, so the authored string reached the component's callback prop
12+
of the same name. The component called that prop, so the first interaction
13+
threw `TypeError: onChange is not a function` (or `onViewChange`) and the event
14+
was never dispatched. This form rendered through `SchemaRenderer` crashed:
15+
16+
```json
17+
{ "type": "sort-ui", "fields": [{ "field": "name" }], "onChange": "myapp:sort-changed" }
18+
```
19+
20+
The three controls now call the prop only when it is a function, and dispatch
21+
the event named on the schema. The listener below receives `e.detail.sort`
22+
(`e.detail.values` for `filter-ui`, `e.detail.view` for `view-switcher`):
23+
24+
```js
25+
window.addEventListener('myapp:sort-changed', (e) => e.detail.sort);
26+
```
27+
28+
A React host that passes a function through `SchemaRenderer` is unaffected.
29+
Its function is called first with the new value. The authored event, if one is
30+
named, is then dispatched as before.

‎packages/plugin-view/src/FilterUI.tsx‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { cva } from 'class-variance-authority';
3131
import { SlidersHorizontal, X } from 'lucide-react';
3232
import type { FilterUISchema } from '@object-ui/types';
33+
import { notifyViewHandlerChannels } from './viewHandlerChannels';
3334

3435
export type FilterUIProps = {
3536
schema: FilterUISchema;
@@ -94,15 +95,10 @@ export const FilterUI: React.FC<FilterUIProps> = ({
9495
}, [schema.values]);
9596

9697
const notifyChange = React.useCallback((nextValues: FilterValue) => {
97-
onChange?.(nextValues);
98-
99-
if (schema.onChange && typeof window !== 'undefined') {
100-
window.dispatchEvent(
101-
new CustomEvent(schema.onChange, {
102-
detail: { values: nextValues },
103-
})
104-
);
105-
}
98+
// Host function, then the authored event name (objectui#6124). The helper
99+
// calls the prop only when it is a function: through `SchemaRenderer` the
100+
// prop can hold the authored string (objectui#10616).
101+
notifyViewHandlerChannels(onChange, schema.onChange, nextValues, { values: nextValues });
106102
}, [onChange, schema.onChange]);
107103

108104
const updateValue = React.useCallback((field: string, value: any) => {

‎packages/plugin-view/src/SortUI.tsx‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { cva } from 'class-variance-authority';
2121
import { ArrowUp } from 'lucide-react';
2222
import type { SortItem } from '@object-ui/components';
2323
import type { SortUISchema } from '@object-ui/types';
24+
import { notifyViewHandlerChannels } from './viewHandlerChannels';
2425

2526
export type SortUIProps = {
2627
schema: SortUISchema;
@@ -88,15 +89,10 @@ export const SortUI: React.FC<SortUIProps> = ({
8889

8990
const notifyChange = React.useCallback((nextSort: SortEntry[]) => {
9091
setSortState(nextSort);
91-
onChange?.(nextSort);
92-
93-
if (schema.onChange && typeof window !== 'undefined') {
94-
window.dispatchEvent(
95-
new CustomEvent(schema.onChange, {
96-
detail: { sort: nextSort },
97-
})
98-
);
99-
}
92+
// Host function, then the authored event name (objectui#6124). The helper
93+
// calls the prop only when it is a function: through `SchemaRenderer` the
94+
// prop can hold the authored string (objectui#10616).
95+
notifyViewHandlerChannels(onChange, schema.onChange, nextSort, { sort: nextSort });
10096
}, [onChange, schema.onChange]);
10197

10298
const handleToggle = React.useCallback((field: string) => {

‎packages/plugin-view/src/ViewSwitcher.tsx‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { cva } from 'class-variance-authority';
2424
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
2525
import type { ViewSwitcherSchema, ViewType } from '@object-ui/types';
26+
import { notifyViewHandlerChannels } from './viewHandlerChannels';
2627
import {
2728
Activity,
2829
Calendar,
@@ -270,15 +271,10 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
270271
}, [activeView, schema.activeView, schema.persistPreference, storageKey]);
271272

272273
const notifyChange = React.useCallback((nextView: ViewType) => {
273-
onViewChange?.(nextView);
274-
275-
if (schema.onViewChange && typeof window !== 'undefined') {
276-
window.dispatchEvent(
277-
new CustomEvent(schema.onViewChange, {
278-
detail: { view: nextView },
279-
})
280-
);
281-
}
274+
// Host function, then the authored event name (objectui#6124). The helper
275+
// calls the prop only when it is a function: through `SchemaRenderer` the
276+
// prop can hold the authored string (objectui#10616).
277+
notifyViewHandlerChannels(onViewChange, schema.onViewChange, nextView, { view: nextView });
282278
}, [onViewChange, schema.onViewChange]);
283279

284280
const handleViewChange = React.useCallback((nextView: ViewType) => {

‎packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsx‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,27 @@
1919
* dispatches). Three rows are the second kind, and the scope extension that
2020
* swept them in for retirement would have DELETED WORKING BEHAVIOUR:
2121
*
22-
* - `ViewSwitcherSchema.onViewChange` — `ViewSwitcher.tsx:249-255`
23-
* - `FilterUISchema.onChange` — `FilterUI.tsx:99-105`
24-
* - `SortUISchema.onChange` — `SortUI.tsx:93-99`
22+
* - `ViewSwitcherSchema.onViewChange` — `ViewSwitcher`'s `notifyChange`
23+
* - `FilterUISchema.onChange` — `FilterUI`'s `notifyChange`
24+
* - `SortUISchema.onChange` — `SortUI`'s `notifyChange`
2525
*
26-
* each `window.dispatchEvent(new CustomEvent(schema.<key>, { detail }))`, i.e.
27-
* the AUTHORED STRING IS THE EVENT NAME. Note the dual channel that produced
28-
* the mislabel: `onViewChange?.(next)` right above it is the REACT PROP (a
26+
* each hands `schema.<key>` to `notifyViewHandlerChannels`
27+
* (`viewHandlerChannels.ts`), which runs
28+
* `window.dispatchEvent(new CustomEvent(schema.<key>, { detail }))`, i.e. the
29+
* AUTHORED STRING IS THE EVENT NAME. Note the dual channel that produced the
30+
* mislabel: the same helper also calls the REACT PROP `onViewChange` (a
2931
* function a host passes), while `schema.onViewChange` is the authored string.
3032
* Same name, two channels — which is exactly what a type-shaped census cannot
3133
* see, and why the `.describe()` text on those three rows now says "event name"
3234
* rather than "callback".
3335
*
36+
* ⚠️ This file renders the components DIRECTLY and mocks `SchemaRenderer`, so
37+
* the callback prop is always `undefined` here. Through the real renderer the
38+
* authored string also lands in that prop, and calling it threw
39+
* `TypeError: onChange is not a function` while every pin below stayed green
40+
* (objectui#10616). That entry is pinned in
41+
* `handlerEventNameThroughRenderer-10616.test.tsx`.
42+
*
3443
* ## Why BOTH halves, and why the declared half is not a `safeParse`
3544
*
3645
* A retirement of one of these keys has two independent failure surfaces, so
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10616 — the three view event-name keys work through the REAL
11+
* `SchemaRenderer`, which is the path an authored document takes.
12+
*
13+
* ## The defect this pins
14+
*
15+
* objectui#6124 declared `FilterUISchema.onChange`, `SortUISchema.onChange`
16+
* and `ViewSwitcherSchema.onViewChange` as EVENT NAMES: the authored string is
17+
* the name of a `CustomEvent` dispatched on `window`. But `SchemaRenderer`
18+
* spreads every non-metadata node key as a React prop, so the same string also
19+
* lands in the component's same-named CALLBACK prop. Each control called that
20+
* prop before dispatching, so the first interaction threw
21+
* `TypeError: onChange is not a function` (`onViewChange` on the switcher) and
22+
* the window event after it never fired.
23+
*
24+
* ## Why this file does not mock `SchemaRenderer`
25+
*
26+
* The objectui#6124 pins (`handlerEventNameLiveness.6124.test.tsx`) render the
27+
* components DIRECTLY and mock `SchemaRenderer`, so the callback prop is always
28+
* `undefined` there and they stayed green over this defect. Those pins still
29+
* own what they own — the keys are declared, and the directly rendered control
30+
* dispatches — and this file owns the other entry: a registered node rendered
31+
* through the real renderer and registry, with and without a host function.
32+
*
33+
* ## What each leg asserts
34+
*
35+
* - AUTHORED string, no host prop: the documented event fires once with the
36+
* documented `detail`, and nothing is reported (no `TypeError`).
37+
* - CONTROL — a host FUNCTION passed through `SchemaRenderer` next to the same
38+
* authored string: the function is called once with the new value AND the
39+
* event still fires. It was green before the fix too; it is here so the fix
40+
* cannot pass by dropping the host channel.
41+
*/
42+
43+
import { describe, it, expect, vi } from 'vitest';
44+
import { render, screen, fireEvent } from '@testing-library/react';
45+
import { SchemaRenderer } from '@object-ui/react';
46+
// Module scope, not a hook: this import IS the registration of
47+
// `filter-ui`, `sort-ui` and `view-switcher` in the real registry.
48+
import '../index';
49+
50+
interface Reading<D> {
51+
/** Every error React or the runtime reported while the interaction ran. */
52+
reported: string[];
53+
/** The `detail` of every `eventName` event dispatched on `window`. */
54+
details: D[];
55+
}
56+
57+
/**
58+
* Run `act` and record what it reported and dispatched.
59+
*
60+
* Errors are read from THREE places because React 19 does not rethrow an
61+
* error thrown inside an event handler out of `fireEvent` — it REPORTS it
62+
* (window `error` event and `console.error`). A bare `expect(...).toThrow()`
63+
* would read a crashing control as green.
64+
*/
65+
function interact<D>(eventName: string, act: () => void): Reading<D> {
66+
const reported: string[] = [];
67+
const details: D[] = [];
68+
const onEvent = (e: Event) => details.push((e as CustomEvent<D>).detail);
69+
const onError = (e: Event) => {
70+
reported.push(String((e as ErrorEvent).error ?? e));
71+
e.preventDefault();
72+
};
73+
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
74+
reported.push(args.map((a) => String(a)).join(' '));
75+
});
76+
window.addEventListener(eventName, onEvent);
77+
window.addEventListener('error', onError);
78+
try {
79+
act();
80+
} catch (err) {
81+
reported.push(String(err));
82+
} finally {
83+
window.removeEventListener(eventName, onEvent);
84+
window.removeEventListener('error', onError);
85+
spy.mockRestore();
86+
}
87+
return { reported, details };
88+
}
89+
90+
describe('filter-ui: an authored onChange event name through SchemaRenderer (objectui#10616)', () => {
91+
const node = {
92+
type: 'filter-ui',
93+
layout: 'inline',
94+
filters: [{ field: 'qty', label: 'Qty', type: 'number' }],
95+
onChange: 'zz10616:filter',
96+
};
97+
const typeSeven = (container: HTMLElement) => {
98+
const input = container.querySelector('input[type="number"]') as HTMLInputElement | null;
99+
expect(input, 'the registered filter-ui rendered no number input').toBeTruthy();
100+
fireEvent.change(input!, { target: { value: '7' } });
101+
};
102+
103+
it('dispatches the named window event with detail { values } and reports nothing', () => {
104+
const { container } = render(<SchemaRenderer schema={node} />);
105+
const { reported, details } = interact<{ values: Record<string, unknown> }>(
106+
'zz10616:filter',
107+
() => typeSeven(container),
108+
);
109+
expect(reported).toEqual([]);
110+
expect(details).toHaveLength(1);
111+
expect(details[0]).toEqual({ values: { qty: 7 } });
112+
});
113+
114+
it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => {
115+
const host = vi.fn();
116+
const { container } = render(<SchemaRenderer schema={node} onChange={host} />);
117+
const { reported, details } = interact<{ values: Record<string, unknown> }>(
118+
'zz10616:filter',
119+
() => typeSeven(container),
120+
);
121+
expect(reported).toEqual([]);
122+
expect(host).toHaveBeenCalledTimes(1);
123+
expect(host).toHaveBeenCalledWith({ qty: 7 });
124+
expect(details).toEqual([{ values: { qty: 7 } }]);
125+
});
126+
});
127+
128+
describe('sort-ui: an authored onChange event name through SchemaRenderer (objectui#10616)', () => {
129+
const node = {
130+
type: 'sort-ui',
131+
variant: 'buttons',
132+
fields: [{ field: 'name', label: 'Name' }],
133+
onChange: 'zz10616:sort',
134+
};
135+
const clickName = () => fireEvent.click(screen.getByRole('button', { name: /name/i }));
136+
137+
it('dispatches the named window event with detail { sort } and reports nothing', () => {
138+
render(<SchemaRenderer schema={node} />);
139+
const { reported, details } = interact<{ sort: unknown }>('zz10616:sort', clickName);
140+
expect(reported).toEqual([]);
141+
expect(details).toEqual([{ sort: [{ field: 'name', direction: 'asc' }] }]);
142+
});
143+
144+
it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => {
145+
const host = vi.fn();
146+
render(<SchemaRenderer schema={node} onChange={host} />);
147+
const { reported, details } = interact<{ sort: unknown }>('zz10616:sort', clickName);
148+
expect(reported).toEqual([]);
149+
expect(host).toHaveBeenCalledTimes(1);
150+
expect(host).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]);
151+
expect(details).toEqual([{ sort: [{ field: 'name', direction: 'asc' }] }]);
152+
});
153+
});
154+
155+
describe('view-switcher: an authored onViewChange event name through SchemaRenderer (objectui#10616)', () => {
156+
const node = {
157+
type: 'view-switcher',
158+
variant: 'buttons',
159+
views: [{ type: 'list' }, { type: 'grid' }],
160+
onViewChange: 'zz10616:view',
161+
};
162+
const clickGrid = () => fireEvent.click(screen.getByRole('button', { name: /grid/i }));
163+
164+
it('dispatches the named window event with detail { view } and reports nothing', () => {
165+
render(<SchemaRenderer schema={node} />);
166+
const { reported, details } = interact<{ view: string }>('zz10616:view', clickGrid);
167+
expect(reported).toEqual([]);
168+
expect(details).toEqual([{ view: 'grid' }]);
169+
});
170+
171+
it('CONTROL: a host function passed through SchemaRenderer is called, and the event still fires', () => {
172+
const host = vi.fn();
173+
render(<SchemaRenderer schema={node} onViewChange={host} />);
174+
const { reported, details } = interact<{ view: string }>('zz10616:view', clickGrid);
175+
expect(reported).toEqual([]);
176+
expect(host).toHaveBeenCalledTimes(1);
177+
expect(host).toHaveBeenCalledWith('grid');
178+
expect(details).toEqual([{ view: 'grid' }]);
179+
});
180+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Deliver a change of `filter-ui`, `sort-ui` or `view-switcher` on both of its
11+
* channels. The three controls call this one function, so they cannot drift
12+
* apart (objectui#10616).
13+
*
14+
* ## The two channels
15+
*
16+
* 1. HOST: the React prop `onChange` / `onViewChange`, holding a FUNCTION a
17+
* host passes. It is called with the new value.
18+
* 2. AUTHORED: the schema key of the same name, holding an EVENT NAME
19+
* (objectui#6124). It is dispatched on `window` as a `CustomEvent` whose
20+
* `detail` is the documented shape: `{ values }`, `{ sort }` or `{ view }`.
21+
*
22+
* ## Why the prop is called only when it is a function
23+
*
24+
* `SchemaRenderer` spreads every non-metadata node key as a React prop, so
25+
* when no host supplies one, the authored event name also lands in the
26+
* same-named callback prop. The controls used to call that prop with `?.()`,
27+
* which does not guard a string: the first interaction threw
28+
* `TypeError: onChange is not a function` and the window event after it never
29+
* fired, so the declared, documented authoring form crashed through the
30+
* renderer. A string in the prop slot is only the spread copy of the authored
31+
* key, not a second channel: the event name is read from `schema`, the one
32+
* place the contract declares it, and the string is never called.
33+
*
34+
* ## When a host function and an authored name are both present
35+
*
36+
* Neither replaces the other. A host function passed through `SchemaRenderer`
37+
* takes the prop slot (its own props are spread last), and the authored name
38+
* is still on `schema`, so both channels deliver, in the order these controls
39+
* always used: the host function first, then the window event.
40+
*/
41+
export function notifyViewHandlerChannels<T>(
42+
hostCallback: ((value: T) => void) | string | undefined,
43+
eventName: string | undefined,
44+
value: T,
45+
detail: Record<string, unknown>,
46+
): void {
47+
if (typeof hostCallback === 'function') {
48+
hostCallback(value);
49+
}
50+
51+
if (eventName && typeof window !== 'undefined') {
52+
window.dispatchEvent(new CustomEvent(eventName, { detail }));
53+
}
54+
}

0 commit comments

Comments
 (0)