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
45 changes: 45 additions & 0 deletions .changeset/8106-listview-callback-param-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-list': minor
---

`ListViewProps.onSortChange` and `onFilterChange` are declared at the types they
actually fire with, instead of `any`.

Both were left `any` by the objectui#4528 sweep that named every other prop on
this interface "at the type each one actually lands on". `dataSource?: any`
carries its own written justification right there; these two carried none, which
is what made this an oversight rather than a decision. The visible cost was that
the package README's documented "With Callbacks" example compiled green on all
four callbacks while only two of them were constrained by anything: assigning
`view` or `search` to a `number` inside the block raised TS2322, and assigning
`sort` or `filters` raised nothing at all.

Each parameter was measured at the emit site rather than read off the
declaration, and the two are asymmetric:

- `onSortChange` now takes `SortItem[]`. Every emit in the component crosses one
boundary, `emitSortChange`, and both of its legs carry that element type: the
array handed in, and `filterPlatformSortableSort`'s return, which is generic in
the element and so preserves whatever it is given. Normalized-vs-raw therefore
does not move the type here, only whether platform-unsortable entries are still
present.
- `onFilterChange` now takes `FilterGroup`. Its one call site passes the toolbar
`FilterBuilder`'s own `onChange` value straight through, beside a
`setCurrentFilters` that is itself state of that type. It is deliberately NOT
the filter AST `normalizeFilters` / `buildEffectiveFilter` speak — those run
later on the query-building path and nothing they produce reaches this
callback. A host receives the builder's group verbatim, which is what lets it
round-trip back in through `initialFilters`.

Both types were already exported from `@object-ui/components` and already
imported by this file; nothing new is minted.

Narrowing a callback parameter is contravariant, so the cost falls only on
handlers that did something with the parameter that `any` alone allowed — a
handler written `(sort: any) => ...` still compiles unchanged. The cost was
measured across every in-repo consumer that passes either callback
(`ObjectView`, in two places, and `InterfaceListPage`) plus every package that
imports `ListView` at all (`@object-ui/app-shell`, `@object-ui/plugin-map`,
`@object-ui/console`): all of them still type-check, and no handler needed
touching. Out-of-repo hosts that destructure or index the parameter in ways only
`any` permitted will now see a real error, which is the point.
26 changes: 24 additions & 2 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,30 @@ export interface ListViewProps {
*/
dataSource?: any;
onViewChange?: (view: ViewType) => void;
onFilterChange?: (filters: any) => void;
onSortChange?: (sort: any) => void;
/**
* Fires with the advanced-filter group the toolbar's `FilterBuilder` emitted.
*
* `FilterGroup` because that is what the one call site actually passes: the
* builder's own `onChange` value, handed straight through beside
* `setCurrentFilters` — itself `React.useState<FilterGroup>`. Deliberately NOT
* the filter AST `normalizeFilters` / `buildEffectiveFilter` speak: those run
* later, on the query-building path, and nothing they produce reaches this
* callback. A host receives the BUILDER's group verbatim, which is what lets
* it round-trip back in through `initialFilters`.
*/
onFilterChange?: (filters: FilterGroup) => void;
/**
* Fires with the view's sort after a builder edit, a header click or a
* "reset to the view's default".
*
* `SortItem[]` because every emit crosses exactly one boundary —
* `emitSortChange` — and both of its legs carry that element type: the array
* passed in, and `filterPlatformSortableSort`'s return, which is generic in
* the element (readonly T[] in, T[] out) and so preserves whatever it is
* given. Normalized-vs-raw therefore does not move the TYPE here; it only
* decides whether platform-unsortable entries are still present (#6455).
*/
onSortChange?: (sort: SortItem[]) => void;
onSearchChange?: (search: string) => void;
/** Called when the user toggles fields via the Hide Fields popover. */
onHiddenFieldsChange?: (hidden: string[]) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

import { describe, it, expect } from 'vitest';
import type { ComponentProps } from 'react';
import type { SortItem, FilterGroup } from '@object-ui/components';
import { ListView, type ListViewProps } from '../ListView';

type Assert<T extends true> = T;
Expand Down Expand Up @@ -89,6 +90,31 @@ type _OnBulkDeleteIsDeclared = Assert<
Equal<CallSiteProps['onBulkDelete'], ((records: any[]) => void) | undefined>
>;

// 8. objectui#8106 — the two callbacks the #4528 sweep left at `any`, pinned
// at the type their emit sites actually fire with. Both were measured at the
// CALL SITE, not read off the declaration, and they are asymmetric on
// purpose: `sort` crosses `emitSortChange`, whose two legs are the array it
// is handed and `filterPlatformSortableSort`'s return — generic in the
// element, so `SortItem[]` either way; `filters` is the toolbar
// `FilterBuilder`'s own `onChange` value passed straight through, which is
// why it is the builder's `FilterGroup` and NOT the later query-path AST.
//
// The `IsAny` halves are the discriminating ones, exactly as in pin 2: a
// re-widening to `any` must fail loudly here rather than silently
// re-admitting every shape the way it did before objectui#8106.
type _OnSortChangeIsDeclared = Assert<
Equal<CallSiteProps['onSortChange'], ((sort: SortItem[]) => void) | undefined>
>;
type _OnSortChangeParamIsNotAny = Assert<
Equal<IsAny<Parameters<NonNullable<CallSiteProps['onSortChange']>>[0]>, false>
>;
type _OnFilterChangeIsDeclared = Assert<
Equal<CallSiteProps['onFilterChange'], ((filters: FilterGroup) => void) | undefined>
>;
type _OnFilterChangeParamIsNotAny = Assert<
Equal<IsAny<Parameters<NonNullable<CallSiteProps['onFilterChange']>>[0]>, false>
>;

describe('objectui#4528 — ListView serves its declared props', () => {
it('pins the resolved call-site props at compile time', () => {
// The assertions are the types above; this body only keeps the file a test.
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-doc-example-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ export const UNGATED_EXAMPLES = {
reason:
'usage fragment: references `items`, which the example never declares',
},
'packages/plugin-list/src/ListView.tsx:808 ListViewHandle': {
'packages/plugin-list/src/ListView.tsx:830 ListViewHandle': {
card: null,
codes: [2304, 2686],
reason:
Expand Down
Loading