From 424acc6b49fb3c8bb53a5a3c9868b6e48f1253fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:25:52 +0000 Subject: [PATCH 1/3] feat(types): declare the five handler keys the `list-view` renderer reads (objectui#7804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zod arm `type: 'list-view'` selects now declares `onAddRecord`, `onBulkAction`, `onDensityChange`, `onNavigate` and `onPageSizeChange` as objectui#6124 RUNTIME SLOTS — a named refusal on the JSON face, a callable twin on the TypeScript face — and the five matching rows leave `KNOWN_UNDECLARED_READS`. Disposition measured per key, not per group. `'retired'` was refused for all five: every one is read AND invoked. The channel is not the same face for all of them, which is what this slice turned on: - `onNavigate` / `onDensityChange` are read off the NODE (`schema.onX`) and already declared on `ListViewRuntimeProps`. - `onAddRecord` / `onBulkAction` / `onPageSizeChange` are read off the PROPS bag (`props.onX`) and already declared as React props on `ListViewProps` in `@object-ui/plugin-list`. ⇒ nothing is added to `ListViewRuntimeProps`; no published interface widens. ⚠️ This arm FEEDS its own TypeScript face — `ListViewSchema` is `z.input` of the mirror intersected with `ListViewRuntimeProps` — and a refusal arm's `z.input` is `never | undefined`, which ANDs a runtime declaration down to `undefined`. Measured before the fix: `ListViewSchema['onNavigate']` resolved to `undefined`, down from its function type, and nothing went red, because `undefined` is assignable to every optional callback parameter the reads hand it. `ListViewAuthored` gives the runtime half precedence so `'runtime-slot'`'s promise stays true, and it is a key-remapped mapped type rather than `Omit` because `Omit` over the passthrough index signature keeps only that signature (measured: `objectName` read `unknown`). Both readings are pinned in `list-view-handler-slots-7804.test.ts`, where they fail at `tsc`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx --- .../7804-list-view-handler-slots-declared.md | 62 +++++++ .../list-view-handler-slots-7804.test.ts | 153 ++++++++++++++++++ packages/types/src/objectql.ts | 49 +++++- packages/types/src/zod/objectql.zod.ts | 51 ++++++ scripts/check-handler-key-read-sites.mjs | 35 +++- 5 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 .changeset/7804-list-view-handler-slots-declared.md create mode 100644 packages/types/src/__tests__/list-view-handler-slots-7804.test.ts diff --git a/.changeset/7804-list-view-handler-slots-declared.md b/.changeset/7804-list-view-handler-slots-declared.md new file mode 100644 index 0000000000..0da6f0ea3d --- /dev/null +++ b/.changeset/7804-list-view-handler-slots-declared.md @@ -0,0 +1,62 @@ +--- +'@object-ui/types': minor +--- + +Declare the five handler keys the `'list-view'` renderer reads (objectui#7804, +the `ListViewSchema` slice). + +The zod arm `type: 'list-view'` selects now declares `onAddRecord`, +`onBulkAction`, `onDensityChange`, `onNavigate` and `onPageSizeChange` as +objectui#6124 RUNTIME SLOTS: a named refusal on the JSON face, a callable twin +on the TypeScript face. + +**Breaking, and measured.** `BaseSchema` ends `.passthrough()`, so a key an arm +does not declare is not refused — it stops being judged and the value is KEPT. +`SchemaRenderer` then spreads every non-metadata top-level key of the node into +the component props bag, so an authored value reaches the read site. Measured on +the unmodified arm: each of the five parsed GREEN with `{"action":"toast"}` +surviving into the parsed output, while `ListView` went on reading and INVOKING +it — `props.onAddRecord?.()` behind the toolbar's add-record button, +`props.onBulkAction?.(action, rows)` behind a bulk-action button, +`props.onPageSizeChange(newSize)` on the pager's `select`, and +`schema.onNavigate` / `schema.onDensityChange` handed to `useNavigationOverlay` +and `useDensityMode`. After this change all five are refused BY NAME with the +objectui#6124 guidance (issue `code: 'custom'` at the key's own path) and the +message points at the node-type spelling. `onRowClick`, still undeclared on the +same arm, is still accepted and still KEPT on the same document — the control +proving the probe distinguishes a refusal from a parser rejecting everything. + +A version shipped as `minor` because this package ships inside the `fixed` +group `.changeset/config.json` enumerates, where any `major` would carry every +member with it, so `major` is unavailable +(`scripts/check-changeset-no-major.mjs`); the accept-set move is the breaking +part. + +**The TypeScript face is narrowed too, and only where it should be.** Unlike +every earlier slice of this card, this arm FEEDS its own declared type: +`ListViewSchema` is `z.input` of the mirror intersected with +`ListViewRuntimeProps`. A refusal arm's `z.input` is `never | undefined`, which +ANDs a runtime declaration down to `undefined` — so declaring the five would +have silently killed `onNavigate` and `onDensityChange` on the TypeScript face +while every gate stayed green. The intersection now gives the runtime half +precedence (`ListViewAuthored`), so: + +- `ListViewSchema['onNavigate']` and `['onDensityChange']` are unchanged — + still the function types `ListViewRuntimeProps` declares, still supplied on + the node by hosts such as `@object-ui/app-shell`'s `ObjectView`. +- `ListViewSchema['onAddRecord']`, `['onBulkAction']` and `['onPageSizeChange']` + narrow from `unknown` (they were only ever reachable through the passthrough + index signature) to `undefined`. That is the intended narrowing: all three are + React props, declared by name on `ListViewProps` in `@object-ui/plugin-list`, + and a host passes them to `` rather than authoring them on the node. +- Nothing is ADDED to `ListViewRuntimeProps`, so no published interface widens. + +**Migration.** Nothing in the corpus has to change: no authored `'list-view'` +document in this repository, its examples or its docs writes any of the five — +they were only ever reachable as host-supplied functions. A React host keeps +supplying them exactly as before. A document that *did* author one was never +running anything: it was being handed an object where a function was expected. + +Per key, not per prefix: the five reach the renderer on two different faces — +two off the node, three off the props bag — and each disposition was assigned +from its own channel, not from its siblings'. diff --git a/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts b/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts new file mode 100644 index 0000000000..60cd4bfbf6 --- /dev/null +++ b/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts @@ -0,0 +1,153 @@ +/** + * 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#7804 — the `list-view` arm declares the five handler keys its + * registered renderer reads, and the TypeScript face survives the declaration. + * + * Two halves, one file, because on THIS arm they are one change. Every earlier + * slice of this card drained a plain `interface X extends BaseSchema`, whose + * TypeScript face is hand-written and therefore untouched by what the mirror + * declares. `ListViewSchema` is not that shape: it is `z.input` of the mirror + * (`ListViewInferred`) intersected with `ListViewRuntimeProps`, so a refusal + * arm — whose `z.input` is `never | undefined` — ANDs a runtime declaration + * down to `undefined`. Measured before the precedence was written: + * `ListViewSchema['onNavigate']` resolved to `undefined`, down from + * `((recordId: string | number, action?: string) => void) | undefined`, and + * NOTHING went red, because `undefined` is assignable to every optional + * callback parameter the reads hand it. + * + * ⇒ the type half below is not decoration. It is the only thing that fails + * when someone collapses `ListViewAuthored` back to a bare intersection, and + * it is compiled — `tsconfig.test.json` type-checks every `*.test.ts` in this + * package, chained from the package's `type-check` script. + * + * ⛔ What this file does NOT assert: that the keys are unreachable. They are + * RUNTIME SLOTS — `onNavigate` / `onDensityChange` reach `ListView` through the + * node (`schema.onX`), the other three through React props declared on + * `ListViewProps` in `@object-ui/plugin-list`. A host still supplies them; what + * is refused is AUTHORING one as JSON, which could only ever hand a call site + * a plain object where it expects a function. + */ +import { describe, it, expect } from 'vitest'; +import { ListViewSchema as ListViewMirror } from '../zod/objectql.zod.js'; +import type { ListViewSchema, ListViewRuntimeProps } from '../objectql.js'; + +/** The five rows this slice drained from `KNOWN_UNDECLARED_READS`. */ +const DECLARED_REFUSALS = [ + 'onAddRecord', + 'onBulkAction', + 'onDensityChange', + 'onNavigate', + 'onPageSizeChange', +] as const; + +/** + * A handler key this arm still does not declare — the LIVE CONTROL. + * + * It is the exact state all five above were in before this slice: undeclared, + * so `BaseSchema.passthrough()` does not refuse it — it stops judging it and + * KEEPS the value. Asserting it is still accepted in the same pass is what + * makes the five refusals a reading rather than a claim about a parser that + * might simply be rejecting everything. + * + * ⚠️ If a later slice declares this key, this control dies silently — so the + * first assertion below fails loudly instead, telling the next author to pick + * a new one rather than lose the control. + */ +const STILL_UNDECLARED = 'onRowClick'; + +/** A minimal node the arm accepts, so a failure can only come from the key under test. */ +function node(extra: Record) { + return { type: 'list-view', objectName: 'accounts', ...extra }; +} + +/** The authored value shape that motivates the whole card: an action object, not a function. */ +const AUTHORED = { action: 'toast' } as const; + +describe('objectui#7804 — the `list-view` arm refuses its five handler keys BY NAME', () => { + it('the control key is genuinely still undeclared on this arm', () => { + expect(Object.keys((ListViewMirror as unknown as { shape: Record }).shape)) + .not.toContain(STILL_UNDECLARED); + }); + + it.each(DECLARED_REFUSALS)('refuses `%s` at its own path, with the #5099 `custom` code', (key) => { + const result = ListViewMirror.safeParse(node({ [key]: AUTHORED })); + + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + const own = issues.filter((issue) => issue.path.length === 1 && issue.path[0] === key); + expect(own).toHaveLength(1); + expect(own[0].code).toBe('custom'); + // The message names the key, so the issue is addressed even when it is read + // without its path — the one contract `handlerKeyRefusal` composes. + expect(own[0].message).toContain(`\`${key}\``); + }); + + it('a LIVE FUNCTION is refused too — the refusal is about the key, not the value', () => { + for (const key of DECLARED_REFUSALS) { + const result = ListViewMirror.safeParse(node({ [key]: () => undefined })); + expect(result.success, `${key} should refuse a function value as well`).toBe(false); + } + }); + + it('⭐ CONTROL — the still-undeclared key is ACCEPTED and its value is KEPT', () => { + const result = ListViewMirror.safeParse(node({ [STILL_UNDECLARED]: AUTHORED })); + + expect(result.success).toBe(true); + // Not merely accepted: the passthrough KEEPS it, which is what carried an + // authored object all the way to a call site expecting a function. + const parsed = (result.success ? result.data : {}) as Record; + expect(parsed[STILL_UNDECLARED]).toEqual(AUTHORED); + }); +}); + +describe('objectui#7804 — the TypeScript face survives the declaration', () => { + it('compiles: the assertions in this file are the check, and tsc is what runs them', () => { + // The work is in the type-level block below; this keeps the suite honest + // about there being a runtime no-op here. + expect(typeof TYPE_PINS).toBe('object'); + }); +}); + +/** + * ⛔ TYPE-LEVEL PINS — these fail at `tsc`, not at runtime. + * + * Each one was `undefined` (or, for `objectName`, `unknown`) under a candidate + * this slice measured and rejected; see `ListViewAuthored` in `../objectql.ts` + * for both readings. + */ +const TYPE_PINS = { + /** The node-read runtime slots stay CALLABLE — what `'runtime-slot'` promises. */ + onNavigate: ((recordId: string | number, action?: string) => { + void recordId; + void action; + }) satisfies NonNullable, + onDensityChange: ((mode: 'compact' | 'comfortable' | 'spacious') => { + void mode; + }) satisfies NonNullable, + /** The non-handler runtime prop rides the same precedence. */ + refreshTrigger: 1 satisfies NonNullable, + /** + * ⭐ THE `Omit` REGRESSION PIN. `Omit` + * keeps ONLY the index signature `BaseSchema.passthrough()` puts on the + * inferred type, so every declared member collapses to `unknown` — measured: + * `objectName` read `unknown` instead of `string`. This is that measurement, + * kept where it fails. + */ + objectName: 'accounts' satisfies ListViewSchema['objectName'], +} as const; + +/** The three PROPS-half keys are NOT authorable on the node type. */ +// @ts-expect-error `onAddRecord` is a React prop on `ListViewProps`, not a node key. +const notOnTheNode: ListViewSchema['onAddRecord'] = () => undefined; +void notOnTheNode; + +/** And the runtime half's own key list is what the precedence is keyed on. */ +const runtimeKeys: Array = ['onNavigate', 'onDensityChange', 'refreshTrigger']; +void runtimeKeys; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 07bdc65918..54c7cb5a6a 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2572,7 +2572,54 @@ export type ViewNavigationConfig = NavigationConfig; * `exportOptions`/`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) remain as sanctioned * local `.extend()`s on the schema; migration to the spec-canonical keys is deferred (#2231). */ -export type ListViewSchema = ListViewInferred & ListViewRuntimeProps; +export type ListViewSchema = ListViewAuthored & ListViewRuntimeProps; + +/** + * The zod-derived AUTHORING half of {@link ListViewSchema}, with every key + * {@link ListViewRuntimeProps} declares removed so the intersection cannot + * annihilate those declarations (objectui#7804). + * + * ## Why this is not `ListViewInferred` directly + * + * The mirror now declares `onNavigate` / `onDensityChange` (and three sibling + * handler keys) as `handlerKeyRefusal` arms, because a registered renderer + * reads them off the authored document and `BaseSchema.passthrough()` was + * KEEPING an authored value. A refusal arm's `z.input` is `never | undefined`, + * and an intersection ANDs the two halves per key: + * + * (never | undefined) & (((recordId, action?) => void) | undefined) + * === undefined + * + * Measured on this tree before the precedence was written: `ListViewSchema`'s + * `onNavigate` and `onDensityChange` resolved to `undefined`, down from the + * function types `ListViewRuntimeProps` declares — and NOTHING went red, + * because `undefined` is assignable to every optional callback parameter the + * reads hand it. That is the silent form of the defect: a `'runtime-slot'` + * disposition promises "the TypeScript twin stays callable", and a bare + * intersection quietly makes it a lie while every gate stays green. + * + * ## Why a key-remapped mapped type and ⛔ not `Omit` + * + * `ListViewInferred` carries a string index signature (`BaseSchema` is + * passthrough), so `keyof` it is `string | number` and + * `Omit` keeps ONLY that index + * signature — every declared member is erased. Measured the same way: + * under `Omit`, `objectName` resolved to `unknown` instead of `string`. It is + * the same trap `ListViewProps` in `@object-ui/plugin-list` documents for + * `PropsWithoutRef`, and it is why this is a homomorphic mapped type with an + * `as` clause: that form drops the named members it is asked to drop and + * carries the index signature through untouched. + * + * This states the precedence the {@link ListViewSchema} docblock always + * claimed — runtime-only props "cannot live in the zod/JSON-schema" — instead + * of leaving it true only for as long as the mirror happened to declare none + * of them. Adding a key to {@link ListViewRuntimeProps} is what takes it off + * the authoring half; the pair is asserted in + * `__tests__/list-view-handler-slots-7804.test.ts`. + */ +type ListViewAuthored = { + [K in keyof ListViewInferred as K extends keyof ListViewRuntimeProps ? never : K]: ListViewInferred[K]; +}; /** * Non-serializable runtime-only props for the ListView component. These never belong in diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index da00feab43..3f8b0de0f6 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1116,6 +1116,57 @@ export const ListViewSchema = BaseSchema calendar: CalendarConfig.optional().describe('Calendar-specific configuration'), gallery: GalleryConfig.optional().describe('Gallery-specific configuration'), timeline: TimelineConfig.optional().describe('Timeline-specific configuration'), + // ⭐ objectui#7804 — the five keys the REGISTERED `list-view` renderer reads + // off the authored document while this arm declared none. `BaseSchema` is + // `.passthrough()`, so an undeclared key is NOT refused: it stops being + // judged and the value is KEPT. `SchemaRenderer` then spreads every + // non-metadata top-level key of the node into the component props bag (the + // rest element of its `= evaluatedSchema` destructure, commented there as + // "Spread non-metadata schema properties as props"), so an authored + // `onAddRecord: { action: 'toast' }` arrives as `props.onAddRecord` and + // `ListView`'s add-record button calls it — a plain object invoked as a + // function, at click. + // + // ⛔ DISPOSITION MEASURED PER KEY, never read off the group. `'retired'` + // publishes "no renderer reads this key"; all five are read AND invoked, so + // it would have published a false sentence for every one of them. They are + // `'runtime-slot'` — and the TypeScript channel each one names is NOT the + // same face, which is the split this slice turned on: + // + // `onNavigate` · `onDensityChange` read off the NODE (`schema.onX`, into + // `useNavigationOverlay` and `useDensityMode`) and declared on + // `ListViewRuntimeProps` (`../objectql.ts`), which is intersected into + // the `ListViewSchema` TYPE precisely so a host can put a function + // there. `@object-ui/app-shell`'s `ObjectView` builds a + // `const fullSchema: ListViewSchema` node carrying `onDensityChange`; + // `onNavigate` has no in-repo supplier on a `list-view` node, yet the + // channel is wired end to end and the read still fires. + // `onAddRecord` · `onBulkAction` · `onPageSizeChange` read off the PROPS + // bag (`props.onX`) and declared as React props on `ListViewProps` + // (`@object-ui/plugin-list`), the interface objectui#4528 wrote out by + // name. `StudioDesignSurface` supplies `onAddRecord` as a React prop; + // the other two have no in-repo supplier and are still read and still + // invoked. + // + // ⇒ nothing is ADDED to `ListViewRuntimeProps` by this slice: every one of + // the five already has a declared TypeScript home, and the three props-half + // keys never belonged on the node type at all. + // + // ⚠️ Unlike the plain `interface X extends BaseSchema` arms this card + // drained before it, THIS arm feeds its own TypeScript face: + // `ListViewInferred` is `z.input` of this schema and `ListViewSchema` + // intersects it with `ListViewRuntimeProps`. A refusal arm's `z.input` is + // `never | undefined`, and `undefined & ((…) => void) | undefined)` is + // `undefined` — so declaring these five here ANNIHILATES the two runtime + // declarations unless the intersection gives the runtime half precedence. + // It now does; see `ListViewAuthored` in `../objectql.ts`, which is what + // keeps `'runtime-slot'`'s promise ("the TypeScript twin stays callable") + // true on this face. ⛔ Do not collapse that back to a bare intersection. + onAddRecord: handlerKeyRefusal('onAddRecord', 'runtime-slot', 'Add-record handler'), + onBulkAction: handlerKeyRefusal('onBulkAction', 'runtime-slot', 'Bulk action handler'), + onDensityChange: handlerKeyRefusal('onDensityChange', 'runtime-slot', 'Row density change handler'), + onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), + onPageSizeChange: handlerKeyRefusal('onPageSizeChange', 'runtime-slot', 'Page size change handler'), }); /** diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index 5b30a0808c..83ba1a1e70 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -196,11 +196,36 @@ export const KNOWN_UNDECLARED_READS = new Map([ // objectui#7742 remedy `objectFields` took one file over, and the arm carries // the tombstone. Draining a row is part of the landing, not cleanup after it: // a row that outlived its read reddens `staleExemptions()` below. - ['list-view::ListViewSchema.onAddRecord', 'objectui#7804'], - ['list-view::ListViewSchema.onBulkAction', 'objectui#7804'], - ['list-view::ListViewSchema.onDensityChange', 'objectui#7804'], - ['list-view::ListViewSchema.onNavigate', 'objectui#7804'], - ['list-view::ListViewSchema.onPageSizeChange', 'objectui#7804'], + // ⭐ ALL FIVE `list-view::ListViewSchema` rows LANDED and are gone — + // objectui#7804's `ListViewSchema` slice. `onAddRecord`, `onBulkAction`, + // `onDensityChange`, `onNavigate` and `onPageSizeChange` are objectui#6124 + // RUNTIME SLOTS on the arm, each measured at its OWN channel rather than + // assumed from its siblings — and the channel is not the same face for all + // five. `onNavigate` / `onDensityChange` are read off the NODE + // (`schema.onX`) and declared on `ListViewRuntimeProps`; the other three are + // read off the PROPS bag (`props.onX`) and declared as React props on + // `ListViewProps` in `@object-ui/plugin-list`. `'retired'` was refused for + // all five: every one is read AND invoked. + // + // ⚠️ This arm is the first one this card drained that FEEDS ITS OWN + // TypeScript face — `ListViewSchema` is `z.input` of the mirror intersected + // with `ListViewRuntimeProps`, and a refusal arm's `z.input` is + // `never | undefined`, which ANDs the runtime declaration down to + // `undefined`. The slice added the precedence (`ListViewAuthored`, + // `packages/types/src/objectql.ts`) in the same stroke; a later arm added to + // a mirror whose `z.input` is someone's declared type needs the same check. + // + // ⚠️ THE FIVE `list::ListSchema` ROWS BELOW ARE THE SAME FIVE READS, + // scored a second time. `@object-ui/plugin-list` registers `ListViewRenderer` + // under `'list'` as well, with `{ namespace: 'view', skipFallback: true }` — + // and `skipFallback` is exactly what stops that alias claiming the bare + // `list` key, which belongs to the bullet/numbered-list DISPLAY primitive in + // `@object-ui/components`. This census keys a registration by its raw type + // string, so the alias's reads are judged against `ListSchema` (`items` / + // `ordered` / `dividers` / `dense`), an arm that has nothing to do with them. + // ⛔ Do NOT drain these by declaring the five on `ListSchema`: that would + // publish a density handler on a bullet list. Their disposition is a + // different question from this slice's and it is not this slice's to answer. ['list::ListSchema.onAddRecord', 'objectui#7804'], ['list::ListSchema.onBulkAction', 'objectui#7804'], ['list::ListSchema.onDensityChange', 'objectui#7804'], From 265832105d76be17776bd9b8a989529262994071 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:52:53 +0000 Subject: [PATCH 2/3] test(types): give the ListView drift guard a category for refusal arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `list-view-spec-parity.test.ts` asks every objectui-only member of the `list-view` arm to be a conscious local-vs-upstream decision. The five `handlerKeyRefusal` arms this slice declared are neither branch it offers: promoting one into `@objectstack/spec` would ask the protocol to declare a key JSON cannot express, and calling it a "genuine objectui-only extension" would say the arm accepts something — it accepts nothing. So they get their own named category rather than a row in `SANCTIONED_LOCAL`, and membership is CHECKED: a new assertion refuses a member the arm does not declare, or that accepts either an authored action object or a function, with an authorable sanctioned-local key (`viewType`) as the lit control in the same pass. The set therefore cannot be used to park a real authorable field outside the drift guard. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx --- .../__tests__/list-view-spec-parity.test.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index 21df5fbb67..5b8626f36e 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -108,6 +108,30 @@ const SANCTIONED_LOCAL = new Set([ 'options', ]); +/** + * A THIRD category, and deliberately not a row in `SANCTIONED_LOCAL`: members + * that are not authorable fields at all (objectui#7804). + * + * Each of these is a `handlerKeyRefusal` arm — a key a REGISTERED renderer + * reads off the authored document, declared here only so that + * `BaseSchema.passthrough()` stops KEEPING an authored value and the author + * gets a refusal that names the key. Neither branch the docblock above offers + * fits one: promoting it into `@objectstack/spec` would ask the protocol to + * declare a key JSON cannot express, and calling it a "genuine objectui-only + * extension" would say the arm accepts something. It accepts nothing. + * + * ⚠️ Membership here is still a deliberate act — and it is CHECKED. The test + * below refuses a member that is not actually a refusal arm, so this set + * cannot be used to park a real authorable field outside the drift guard. + */ +const HANDLER_KEY_REFUSALS = new Set([ + 'onAddRecord', + 'onBulkAction', + 'onDensityChange', + 'onNavigate', + 'onPageSizeChange', +]); + describe('ListView spec parity (#2231 drift guard)', () => { it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => { // Fails when the spec adds a field that objectui neither imports nor envelope-owns — @@ -126,11 +150,42 @@ describe('ListView spec parity (#2231 drift guard)', () => { it('declares no objectui-only field outside the sanctioned-local set', () => { // Fails when a new objectui-only field is added without deciding local-vs-upstream. const rogue = [...ouiKeys].filter( - (k) => !specShape[k] && !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k), + (k) => + !specShape[k] && + !ENVELOPE.has(k) && + !SANCTIONED_LOCAL.has(k) && + !HANDLER_KEY_REFUSALS.has(k), ); expect(rogue).toEqual([]); }); + it('every HANDLER_KEY_REFUSALS member really refuses — the set cannot hide an authorable field', () => { + const node = (extra: Record) => ({ + type: 'list-view', + objectName: 'accounts', + ...extra, + }); + + // ⭐ CONTROL first: the same probe on a sanctioned-local key that IS + // authorable must be accepted, or the loop below proves nothing. + expect(OuiListViewSchema.safeParse(node({ viewType: 'grid' })).success).toBe(true); + + for (const key of HANDLER_KEY_REFUSALS) { + expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true); + // Both faces of "accepts nothing": the authored action object this card + // exists for, and a live function, which is the only value a host could + // ever have meant. + expect( + OuiListViewSchema.safeParse(node({ [key]: { action: 'toast' } })).success, + `${key} must refuse an authored action object`, + ).toBe(false); + expect( + OuiListViewSchema.safeParse(node({ [key]: () => undefined })).success, + `${key} must refuse a function value too`, + ).toBe(false); + } + }); + it('preserves the component discriminator + required objectName', () => { const bad = OuiListViewSchema.safeParse({ objectName: 'accounts' }); // no type expect(bad.success).toBe(false); From cd4eb52aa81c5bc565ba2e3e1700aaff285b713c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:19:17 +0000 Subject: [PATCH 3/3] fix(types): declare the three props-half slots on `ListViewRuntimeProps` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects this branch's own first reading. I had concluded that nothing needed adding to `ListViewRuntimeProps` because `onAddRecord` / `onBulkAction` / `onPageSizeChange` are read off the PROPS bag and `ListViewProps` (`@object-ui/plugin-list`) already declares all three as React props. ⛔ That measured only ONE of the two supply paths into that bag. `SchemaRenderer` spreads every non-metadata top-level key of the node into the props it creates the component with, so a host that builds the NODE in TypeScript reaches the same read — and that path was typed only by `BaseSchema`'s passthrough index signature. Declaring the refusal arms without declaring the slots would have narrowed a live programmatic channel from `unknown` to `undefined`, which is wider than this card asks for and is not what `'runtime-slot'` means. It is the same repair objectui#9344's slice made on `ObjectGallerySchema` for the same spread, and it means a published interface WIDENS here as well as narrowing — the changeset says so. `ObjectView.relayRungCensus-7559.test.ts` follows from that: the three entered its derived population with this declaration, and its own failure text says the answer is owed by the change that added them. Three `host-runtime` absences, the existing kind whose validator requires exactly `ListViewRuntimeProps` membership — no new absence kind, no new assertion. ⚠️ FILE SURFACE: that census file is outside this claim's declared surface (`packages/types/src/`, `scripts/check-handler-key-read-sites.mjs`, one changeset). It is reported rather than hidden; see the pull request body. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx --- .../7804-list-view-handler-slots-declared.md | 19 ++++++-- .../ObjectView.relayRungCensus-7559.test.ts | 8 ++++ .../list-view-handler-slots-7804.test.ts | 48 ++++++++++++++----- packages/types/src/objectql.ts | 36 ++++++++++++++ packages/types/src/zod/objectql.zod.ts | 19 ++++---- 5 files changed, 105 insertions(+), 25 deletions(-) diff --git a/.changeset/7804-list-view-handler-slots-declared.md b/.changeset/7804-list-view-handler-slots-declared.md index 0da6f0ea3d..7fdb14c540 100644 --- a/.changeset/7804-list-view-handler-slots-declared.md +++ b/.changeset/7804-list-view-handler-slots-declared.md @@ -45,11 +45,20 @@ precedence (`ListViewAuthored`), so: still the function types `ListViewRuntimeProps` declares, still supplied on the node by hosts such as `@object-ui/app-shell`'s `ObjectView`. - `ListViewSchema['onAddRecord']`, `['onBulkAction']` and `['onPageSizeChange']` - narrow from `unknown` (they were only ever reachable through the passthrough - index signature) to `undefined`. That is the intended narrowing: all three are - React props, declared by name on `ListViewProps` in `@object-ui/plugin-list`, - and a host passes them to `` rather than authoring them on the node. -- Nothing is ADDED to `ListViewRuntimeProps`, so no published interface widens. + are now DECLARED on `ListViewRuntimeProps` with the signatures `ListViewProps` + in `@object-ui/plugin-list` already carried, where before they were typed only + by `BaseSchema`'s passthrough index signature — `unknown`, a declaration + nobody wrote and nobody can read. `ListView` reads all three off its props + bag, and a host fills that bag either by passing the React prop or by putting + the key on the node, where `SchemaRenderer` spreads it in; this declaration is + the second path's contract. Same repair as the `ObjectGallerySchema` pair one + slice earlier, for the same spread. + +**A published interface therefore WIDENS as well as narrowing.** Three members +are added to `ListViewRuntimeProps`. Nothing that compiled before stops +compiling — `unknown` accepted any host handler and the declared signatures +accept the ones `ListViewProps` already held hosts to — but the surface is +larger, and it is stated here rather than left to be discovered. **Migration.** Nothing in the corpus has to change: no authored `'list-view'` document in this repository, its examples or its docs writes any of the five — diff --git a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index d19f52eee8..d101e1bb72 100644 --- a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -482,6 +482,14 @@ const ABSENCES: Record = { // ── The runtime-only half of the intersection ───────────────────────────── onNavigate: { kind: 'host-runtime', reason: 'Host callback. This host wires record navigation through the `onRowClick` prop on the `` element instead; a view record cannot carry a function.' }, refreshTrigger: { kind: 'host-runtime', reason: 'Host refresh counter, supplied by the caller; not view metadata.' }, + // ⭐ objectui#7804 declared these three on `ListViewRuntimeProps`, so they + // entered this census's population with that slice and owe an answer here. + // All three are host callbacks `ListView` reads off its PROPS bag; this host + // supplies none of them and a view RECORD cannot carry a function, so there is + // no rung to add — the absence is the whole of their contract on this path. + onAddRecord: { kind: 'host-runtime', reason: 'Host callback for the toolbar "+ New" affordance. This host routes record creation through its own action layer rather than through ListView, and a view record cannot carry a function.' }, + onBulkAction: { kind: 'host-runtime', reason: 'Host callback for non-delete bulk actions. This host wires bulk delete through the `onBulkDelete` prop on the `` element and offers no other bulk action here; a view record cannot carry a function.' }, + onPageSizeChange: { kind: 'host-runtime', reason: 'Host callback for the pager\'s page-size select. This host lets ListView keep page size as session state rather than persisting it, the same posture as `onFilterChange` (objectui#4155); a view record cannot carry a function.' }, }; // --------------------------------------------------------------------------- diff --git a/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts b/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts index 60cd4bfbf6..54ef047c4d 100644 --- a/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts +++ b/packages/types/src/__tests__/list-view-handler-slots-7804.test.ts @@ -28,11 +28,12 @@ * package, chained from the package's `type-check` script. * * ⛔ What this file does NOT assert: that the keys are unreachable. They are - * RUNTIME SLOTS — `onNavigate` / `onDensityChange` reach `ListView` through the - * node (`schema.onX`), the other three through React props declared on - * `ListViewProps` in `@object-ui/plugin-list`. A host still supplies them; what - * is refused is AUTHORING one as JSON, which could only ever hand a call site - * a plain object where it expects a function. + * RUNTIME SLOTS. `onNavigate` / `onDensityChange` reach `ListView` off the node + * (`schema.onX`); the other three reach it off the PROPS bag, which a host fills + * either by passing the React prop `ListViewProps` declares or by putting the + * key on the NODE, where `SchemaRenderer` spreads it in. A host still supplies + * all five; what is refused is AUTHORING one as JSON, which could only ever hand + * a call site a plain object where it expects a function. */ import { describe, it, expect } from 'vitest'; import { ListViewSchema as ListViewMirror } from '../zod/objectql.zod.js'; @@ -143,11 +144,36 @@ const TYPE_PINS = { objectName: 'accounts' satisfies ListViewSchema['objectName'], } as const; -/** The three PROPS-half keys are NOT authorable on the node type. */ -// @ts-expect-error `onAddRecord` is a React prop on `ListViewProps`, not a node key. -const notOnTheNode: ListViewSchema['onAddRecord'] = () => undefined; -void notOnTheNode; +/** + * The three PROPS-half slots stay CALLABLE on the node type too — the second + * supply path (`SchemaRenderer` spreading a node key into the props bag) is what + * this declaration contracts, and it is exactly what the refusal arm would have + * killed without the precedence. + */ +const propsHalf = { + onAddRecord: (() => undefined) satisfies NonNullable, + onBulkAction: ((action: string, records: unknown[]) => { + void action; + void records; + }) satisfies NonNullable, + onPageSizeChange: ((size: number) => { + void size; + }) satisfies NonNullable, +} as const; +void propsHalf; -/** And the runtime half's own key list is what the precedence is keyed on. */ -const runtimeKeys: Array = ['onNavigate', 'onDensityChange', 'refreshTrigger']; +/** + * ⛔ And the precedence is keyed on `ListViewRuntimeProps`' own member list, so + * a sixth refusal arm added to the mirror without a matching declaration here + * would go back to resolving as `undefined`. This is that list, stated where it + * fails if a member leaves it. + */ +const runtimeKeys: Array = [ + 'onNavigate', + 'onDensityChange', + 'refreshTrigger', + 'onAddRecord', + 'onBulkAction', + 'onPageSizeChange', +]; void runtimeKeys; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 54c7cb5a6a..bef2ae6d2b 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2644,6 +2644,42 @@ export interface ListViewRuntimeProps { * Used by parent components (e.g. ObjectView) to signal that a mutation occurred. */ refreshTrigger?: number; + + /** + * ⭐ The three slots below are declared here by objectui#7804, and the reason is + * the one objectui#9344's slice already measured on `ObjectGallerySchema`: a key + * that reaches the renderer through `SchemaRenderer`'s props spread is on the + * TypeScript face whether or not anyone declared it — `BaseSchema`'s index + * signature was typing all three `unknown`, which is a declaration nobody wrote + * and nobody can read. + * + * `ListView` reads them off its PROPS bag (`props.onAddRecord`, not + * `schema.onAddRecord`), and there are two supply paths into that bag, both + * live: a React host renders the component and passes the prop directly — the + * `ListViewProps` interface in `@object-ui/plugin-list` declares all three by + * name for exactly that — or a host builds the NODE in TypeScript and + * `SchemaRenderer` spreads every non-metadata top-level key into the props it + * creates the component with. This declaration is the second path's contract. + * Declaring it is what keeps the `'runtime-slot'` disposition on the matching + * zod arms true: JSON cannot author a function, so the mirror refuses the key + * by name, while a programmatic host goes on supplying one HERE. + * + * ⚠️ Signatures match `ListViewProps` deliberately, `any` included. A host that + * discovered the payload from the implementation annotated its own handler + * against that interface, and a narrower declaration here refuses such a host + * CONTRAVARIANTLY — the reading objectui#9341 took on + * `ObjectKanbanSchema.onCardClick` and the reason `ListViewProps.onRowClick` + * carries the same spelling. + */ + + /** Called when the user asks for a new record (toolbar "+ New" and the empty-state CTA). */ + onAddRecord?: () => void; + + /** Called with a non-delete bulk action key and the currently selected rows. */ + onBulkAction?: (action: string, records: any[]) => void; + + /** Called when the user picks a different page size in the pager. */ + onPageSizeChange?: (pageSize: number) => void; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 3f8b0de0f6..5838db545e 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1142,15 +1142,16 @@ export const ListViewSchema = BaseSchema // `onNavigate` has no in-repo supplier on a `list-view` node, yet the // channel is wired end to end and the read still fires. // `onAddRecord` · `onBulkAction` · `onPageSizeChange` read off the PROPS - // bag (`props.onX`) and declared as React props on `ListViewProps` - // (`@object-ui/plugin-list`), the interface objectui#4528 wrote out by - // name. `StudioDesignSurface` supplies `onAddRecord` as a React prop; - // the other two have no in-repo supplier and are still read and still - // invoked. - // - // ⇒ nothing is ADDED to `ListViewRuntimeProps` by this slice: every one of - // the five already has a declared TypeScript home, and the three props-half - // keys never belonged on the node type at all. + // bag (`props.onX`). TWO supply paths reach that bag and both are live: + // a React host passes the prop to the component — `ListViewProps` + // (`@object-ui/plugin-list`) declares all three by name, and + // `StudioDesignSurface` supplies `onAddRecord` that way — or a host + // builds the NODE and `SchemaRenderer` spreads it in. The second path + // was typed only by `BaseSchema`'s index signature, so this slice + // declares the three on `ListViewRuntimeProps` as well, which is the + // same repair objectui#9344's slice made on `ObjectGallerySchema` for + // the same spread. `onBulkAction` / `onPageSizeChange` have no in-repo + // supplier and are still read and still invoked. // // ⚠️ Unlike the plain `interface X extends BaseSchema` arms this card // drained before it, THIS arm feeds its own TypeScript face: