Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/10616-view-handler-event-names-through-renderer.md
Original file line number Diff line number Diff line change
@@ -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 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:

```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.
14 changes: 5 additions & 9 deletions packages/plugin-view/src/FilterUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,15 +95,10 @@ export const FilterUI: React.FC<FilterUIProps> = ({
}, [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) => {
Expand Down
14 changes: 5 additions & 9 deletions packages/plugin-view/src/SortUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,15 +89,10 @@ export const SortUI: React.FC<SortUIProps> = ({

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) => {
Expand Down
14 changes: 5 additions & 9 deletions packages/plugin-view/src/ViewSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -270,15 +271,10 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
}, [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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>, { 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.<key>` to `notifyViewHandlerChannels`
* (`viewHandlerChannels.ts`), which runs
* `window.dispatchEvent(new CustomEvent(schema.<key>, { 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<D> {
/** 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<D>(eventName: string, act: () => void): Reading<D> {
const reported: string[] = [];
const details: D[] = [];
const onEvent = (e: Event) => details.push((e as CustomEvent<D>).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(<SchemaRenderer schema={node} />);
const { reported, details } = interact<{ values: Record<string, unknown> }>(
'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(<SchemaRenderer schema={node} onChange={host} />);
const { reported, details } = interact<{ values: Record<string, unknown> }>(
'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(<SchemaRenderer schema={node} />);
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(<SchemaRenderer schema={node} onChange={host} />);
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(<SchemaRenderer schema={node} />);
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(<SchemaRenderer schema={node} onViewChange={host} />);
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' }]);
});
});
54 changes: 54 additions & 0 deletions packages/plugin-view/src/viewHandlerChannels.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
hostCallback: ((value: T) => void) | string | undefined,
eventName: string | undefined,
value: T,
detail: Record<string, unknown>,
): void {
if (typeof hostCallback === 'function') {
hostCallback(value);
}

if (eventName && typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
}
Loading