From e4afc5f95bdf014553696f9f35f885b2e427b25e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:17:29 +0000 Subject: [PATCH 1/2] fix(plugin-list): declare onSortChange/onFilterChange at the types they fire with (objectui#8106) The objectui#4528 sweep named every other prop on `ListViewProps` "at the type each one actually lands on" and left these two at `any`. `dataSource?: any` carries its own written justification in the same file; these two carried none. Each parameter was measured at its emit site, not read off the declaration, and the two are asymmetric. `sort` crosses one boundary, `emitSortChange`, whose two legs are the array handed in and `filterPlatformSortableSort`'s return - generic in the element, so `SortItem[]` either way; normalized-vs-raw does not move the type here. `filters` is the toolbar `FilterBuilder`'s own `onChange` value passed straight through, so it is the builder's `FilterGroup`, deliberately not the later query-path AST that `normalizeFilters` / `buildEffectiveFilter` speak. Both types were already exported by `@object-ui/components` and already imported by this file. Pinned in the #4528 props-resolution test, including the `IsAny` halves that make a future re-widening fail loudly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../8106-listview-callback-param-types.md | 45 +++++++++++++++++++ packages/plugin-list/src/ListView.tsx | 26 ++++++++++- .../ListView.propsResolution.test.ts | 26 +++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changeset/8106-listview-callback-param-types.md diff --git a/.changeset/8106-listview-callback-param-types.md b/.changeset/8106-listview-callback-param-types.md new file mode 100644 index 0000000000..44b5f44574 --- /dev/null +++ b/.changeset/8106-listview-callback-param-types.md @@ -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. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index aae0b1da25..8f48a253bb 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -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`. 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; diff --git a/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts b/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts index 8678f99f4d..348b377eeb 100644 --- a/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts +++ b/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts @@ -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; @@ -89,6 +90,31 @@ type _OnBulkDeleteIsDeclared = Assert< Equal 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 void) | undefined> +>; +type _OnSortChangeParamIsNotAny = Assert< + Equal>[0]>, false> +>; +type _OnFilterChangeIsDeclared = Assert< + Equal void) | undefined> +>; +type _OnFilterChangeParamIsNotAny = Assert< + Equal>[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. From 14f809720496c6ad6acf43d61bb205a1690c48cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:32:14 +0000 Subject: [PATCH 2/2] fix(scripts): re-key the ListViewHandle example ledger row to its moved line (objectui#8106) `UNGATED_EXAMPLES` keys each accepted `@example` failure by a string that embeds the block's LINE NUMBER. The narrowing in the previous commit added lines above `ListViewHandle`'s doc comment, moving its `@example` tag from line 808 to line 830, so the ledger row matched no block (stale) and the block itself read as an undeclared failure - the same three diagnostics, [2304, 2686], that were already accepted at 808. Only the key's line number changes. The row's `card`, `codes` and `reason` are untouched, no row is added or removed, and the example itself is deliberately left failing. The block was re-located BY ANCHOR - the `@example` tag on `ListViewHandle` - and confirmed against the base, where the same tag sits at 808. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- scripts/check-doc-example-types.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index cb5f9c4da7..a22837f6d4 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -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: