From 42b568c6dac20cd7a0cc02d5bcd23ed61795a6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:07:06 +0000 Subject: [PATCH 1/2] refactor(plugin-grid): retire the redundant ObjectGridColumnHolds.headerIcon hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectGridColumnHolds` exists for keys `data-table` reads that `TableColumn` does not declare. `headerIcon` stopped meeting that entry condition when objectui#6615 declared it on `TableColumn`, and nothing went red at the moment of loss — the same silent expiry recorded for `options`. Measured rather than derived (the previous round could only derive it, because the file was fenced behind another claim): - `Equal` is `true`, and so is the `ObjectGridColumnDraft` form. Instrument controls in the same shape answer `false`/`true` as expected, so the probe has resolution. - Ablation on the resolved emit types (every member's type printed via the TypeScript checker, through the same program the test project uses): with the member deleted both types are byte-identical at 27 members. Positive control — deleting `pinned` instead takes them to 26, `pinned` gone. - `headerIcon` is not a `ListColumn` member (control: `width` is), so it was never in the derived `RetiredListColumnKey` band (control: `wrap` is). `pinned` is untouched and stays load-bearing on both counts: `TableColumn` does not declare it and the band carves it out, so the hold is its only declaration on the emit types, and this file's reorder pass re-expresses it as the sticky `className` `data-table` actually reads. Removing the hold moves a liveness claim rather than deleting one: the emit types now get `headerIcon` from `TableColumn` alone. `columnHoldsExpiry-6424` pins that dependency, so reverting the declaration is loud instead of silent. Also corrects the docblock's general claim that the held keys are "undeclared by `TableColumn`", false as written since #6615, in the places #6651 left alone — including the emit-boundary pin that still said the interface declares two keys. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- packages/plugin-grid/src/ObjectGrid.tsx | 46 +++++- .../__tests__/columnEmitBoundary-6004.test.ts | 27 +-- .../__tests__/columnHoldsExpiry-6424.test.ts | 156 ++++++++++++++++++ 3 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/columnHoldsExpiry-6424.test.ts diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 0905b2ccc1..edde494e5d 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -544,10 +544,17 @@ function normalizeColumns( * * Verdicts, each with the read-count behind it: * - * - `headerIcon` — HELD. Live: `data-table.tsx` renders it into the header - * cell (2 reads). Whether `TableColumn` should DECLARE it is objectui#6424's - * call, not this card's; declared here at the seam meanwhile, so the hold is - * visible instead of anonymous. + * - `headerIcon` — DECLARED by `TableColumn`, so NOT held. Live: + * `data-table.tsx` renders it into the header cell (1 render site, 2 + * syntactic reads), forwarded verbatim and never re-expressed. It WAS held + * here, on the "undeclared by `TableColumn`" premise; objectui#6615 + * declared it and that premise expired with nothing going red at the moment + * of loss. objectui#6424 then removed the hold, MEASURED rather than + * derived: with the member deleted both emit types below are byte-identical + * (27 members, every member's resolved type unchanged), against a positive + * control that deleting `pinned` instead moves them to 26. Pinned in + * `columnHoldsExpiry-6424.test.ts` — which now also carries the claim the + * hold used to carry implicitly, that `TableColumn` DECLARES the key. * - `pinned` — HELD. Live, and consumed BEFORE the slot: the reorder pass * below reads it (5 reads in this file) and re-expresses it as the sticky * `className` that `data-table` actually reads. `data-table` never reads @@ -594,11 +601,34 @@ function normalizeColumns( */ export type RetiredListColumnKey = Exclude; -/** The undeclared-but-live keys this producer holds. See the docblock above. */ +/** + * The undeclared-but-live keys this producer holds. See the docblock above. + * + * ⚠️ "Undeclared by `TableColumn`" is this interface's ENTRY CONDITION, and it + * is a claim about ANOTHER package that can stop being true with nothing going + * red here. So it is re-checked per key when the card owning that key closes, + * never inherited: `headerIcon` sat here on exactly that premise until + * objectui#6615 declared it on `TableColumn`, at which point the hold was + * redundant rather than load-bearing, and objectui#6424 removed it. + * + * ⛔ A key whose ONLY declaration on the emit types is this interface is not in + * that position — deleting it deletes the key from the emit. `pinned` is that + * key today, which is why the two verdicts differ. + */ export interface ObjectGridColumnHolds { - /** HELD, objectui#6424 — `data-table` renders it; `TableColumn` does not declare it. */ - headerIcon?: React.ReactNode; - /** HELD — consumed by this file's own reorder pass before the array reaches the slot. */ + /** + * HELD, load-bearing on BOTH counts — the pair that has to hold for a hold to + * be real, and the contrast that made `headerIcon`'s removal safe: + * + * 1. `TableColumn` does NOT declare it, and `RetiredListColumnKey` carves it + * out of the derived tombstone band, so this member is its ONLY + * declaration on both emit types. Deleting it drops the key from them + * (measured: 27 members → 26). + * 2. It has a live consumer BEFORE the slot — this file's own reorder pass + * reads it and re-expresses it as the sticky `className` that + * `data-table` actually reads. That is the "second road" the emit rule + * demands before a key may be retired, and `wrap` failed it. + */ pinned?: 'left' | 'right'; } diff --git a/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts b/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts index f40ed4b8a0..8c70a5e476 100644 --- a/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts +++ b/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts @@ -139,19 +139,22 @@ describe('objectui#6004 — the emit boundary is an instrument, not a decoration }); /** - * The HELD keys — the two `ObjectGridColumnHolds` still declares, which the - * emit type must therefore ACCEPT: a tombstone set that swallowed either + * Both keys the emit type must ACCEPT: a tombstone set that swallowed either * would be a behaviour change wearing a type change's clothes. * - * ⚠️ Their two holds no longer rest on the same footing, and that is - * objectui#6424's to settle rather than this pin's. `pinned` is undeclared by - * `TableColumn` and has a measured live reader — `ObjectGrid`'s own reorder - * pass, which consumes it before the array reaches the slot. `headerIcon` was - * held on the same "undeclared by `TableColumn`" premise, and that premise has - * since expired: `TableColumn` DOES declare it today - * (`packages/types/src/data-display.ts`), so its entry in - * `ObjectGridColumnHolds` is redundant rather than load-bearing. This test - * pins only that both are accepted, which is true either way. + * ⚠️ They no longer arrive by the same route, and objectui#6424 settled + * that. `pinned` is still HELD: undeclared by `TableColumn`, so + * `ObjectGridColumnHolds` is its only declaration on the emit types, AND it + * has a measured live reader — `ObjectGrid`'s own reorder pass, which + * consumes it before the array reaches the slot. `headerIcon` reaches the + * emit types through `TableColumn` itself: it was held on the same + * "undeclared by `TableColumn`" premise, objectui#6615 declared it + * (`packages/types/src/data-display.ts`) and the premise expired with nothing + * going red, so objectui#6424 removed the hold after measuring that both emit + * types are unchanged without it. So `ObjectGridColumnHolds` declares ONE key + * today, not two. This test pins only that both keys are accepted, which is + * true either way; the routes themselves are pinned in + * `columnHoldsExpiry-6424.test.ts`. * * ⚠️ There were three until objectui#5453. `wrap` was the third, and it was * held for a reason that was never "a live reader": the card that owned it @@ -161,7 +164,7 @@ describe('objectui#6004 — the emit boundary is an instrument, not a decoration * open card is a hold with no evidence under it yet, and it should be * re-checked the moment the card closes rather than aging into a fact. */ - it('accepts the two held keys — headerIcon, pinned', () => { + it('accepts headerIcon (via TableColumn) and pinned (via the hold)', () => { const held = { header: 'H', accessorKey: 'a', headerIcon: null, pinned: 'left' as const }; const accepted: ObjectGridColumnDraft = held; expect(accepted.pinned).toBe('left'); diff --git a/packages/plugin-grid/src/__tests__/columnHoldsExpiry-6424.test.ts b/packages/plugin-grid/src/__tests__/columnHoldsExpiry-6424.test.ts new file mode 100644 index 0000000000..eebaadd3b1 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/columnHoldsExpiry-6424.test.ts @@ -0,0 +1,156 @@ +/** + * 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#6424 — `ObjectGridColumnHolds` after the `headerIcon` hold came out. + * + * ## What actually changed, and what this file is for + * + * `ObjectGridColumnHolds` exists for keys `data-table` READS that `TableColumn` + * does NOT declare. `headerIcon` was one until objectui#6615 declared it on + * `TableColumn`; from that moment the hold was redundant rather than + * load-bearing, and NOTHING WENT RED — the same silent expiry objectui#6425 + * recorded for `options`. objectui#6424 removed the member. + * + * ⚠️ THE REMOVAL MOVED A LIVENESS CLAIM RATHER THAN DELETING ONE. While the + * hold existed, the emit types declared `headerIcon` on their own account; now + * they get it ONLY from `TableColumn`. So if `TableColumn` ever stops declaring + * it, the key silently vanishes from both emit types and `ObjectGrid` goes on + * writing it at three sites into a slot that no longer admits it. That is a + * NEW exposure created by the removal, and the first assertion below is what + * covers it — it is the guard the deleted member used to provide implicitly. + * + * ## Why these pins are compile-time + * + * The claim is about TYPES, and a rendering test is blind to it: the grid + * renders exactly as correctly with every type in this file deleted. What can + * fail is a compile, and `tsc -p tsconfig.test.json` reads this file (the + * package build's own program excludes `__tests__`). The `expect()` calls are + * there so vitest reports the file at all; the assertions that matter are the + * `Expect<...>` aliases, which are erased at runtime. + * + * ## Every zero here has a positive control in the same query shape + * + * A `false` from a probe that can only answer `false` measures nothing, so each + * negative claim is paired with a sibling that must come back the other way. + */ +import { describe, it, expect } from 'vitest'; +import type { ListColumn, TableColumn } from '@object-ui/types'; +import type { ObjectGridColumn, ObjectGridColumnDraft, RetiredListColumnKey } from '../ObjectGrid'; + +/** Compile-time equality, exact in both directions (not mutual assignability). */ +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) + ? true + : false; + +type Has = K extends keyof T ? true : false; +type Expect = T; + +describe('objectui#6424 — the holds interface after the headerIcon expiry', () => { + /** + * ⭐ THE GUARD THE REMOVED MEMBER USED TO PROVIDE. + * + * `ObjectGrid` writes `headerIcon` at three sites (all under + * `schema.showColumnTypeIcons`, via `getTypeIcon`) and `data-table` renders + * it. With the hold gone, `TableColumn` is the ONLY thing declaring the key + * on the emit types. Reverting objectui#6615 would otherwise drop it from + * both types in silence; this is what makes that loud. + */ + it('`TableColumn` declares `headerIcon` — the emit types now depend on it alone', () => { + type _Declared = Expect>; + // Controls: the probe can answer both ways in this exact shape. + type _CtrlPresent = Expect>; + type _CtrlAbsent = Expect extends false ? true : false>; + expect(true).toBe(true); + }); + + /** + * ⭐ THE RULED PROBE (objectui#6424, the seat's Option A). + * + * The removal was ruled on a type-algebra derivation — the holds member + * declared the identical type, so the intersection was idempotent. This runs + * it as a compile-time probe instead of inheriting it: the emit types' member + * and `TableColumn`'s must be THE SAME TYPE, not merely compatible, which is + * why this is `Equal` and not `extends`. + */ + it('`headerIcon` on both emit types is identical to `TableColumn`s', () => { + type _Column = Expect>; + type _Draft = Expect>; + // Controls on the instrument itself: `Equal` must be able to say false here, + // otherwise the two lines above are green for no reason. + type _CtrlTrue = Expect>; + type _CtrlFalse = Expect< + Equal extends false ? true : false + >; + expect(true).toBe(true); + }); + + /** + * The hold was never rescuing `headerIcon` from the DERIVED tombstone band. + * `RetiredListColumnKey` is `Exclude`, + * so a key that is not a `ListColumn` member can never enter it — and + * `headerIcon` is not one. Without this, "removing the hold is safe" would + * have to assume the band, and the band is exactly where a removed hold would + * bite (as `?: never`) instead of merely disappearing. + */ + it('`headerIcon` is not a `ListColumn` member, so it was never in the derived band', () => { + type _NotListColumn = Expect extends false ? true : false>; + type _NotInBand = Expect< + 'headerIcon' extends RetiredListColumnKey ? false : true + >; + // Controls, same shapes: a real `ListColumn` member, and a key that IS in the band. + type _CtrlListColumn = Expect>; + type _CtrlInBand = Expect<'wrap' extends RetiredListColumnKey ? true : false>; + expect(true).toBe(true); + }); + + /** + * ⭐ THE CONTRAST THAT MAKES THE TWO VERDICTS DIFFERENT — and the reason + * `pinned` must NOT follow `headerIcon` out. + * + * `pinned` fails the expiry test on both counts: `TableColumn` does not + * declare it, and `RetiredListColumnKey` explicitly carves it out of the + * Exclude, so `ObjectGridColumnHolds` is its ONLY declaration on the emit + * types. (Measured: deleting the member takes both emit types from 27 + * resolved members to 26, `pinned` gone. Deleting `headerIcon` left them + * byte-identical — that pair of ablations is what this verdict rests on.) + * + * It also has the second road `wrap` lacked: this file's reorder pass reads + * it and re-expresses it as the sticky `className` `data-table` reads. + */ + it('`pinned` is still load-bearing — undeclared by `TableColumn` and outside the band', () => { + type _Undeclared = Expect extends false ? true : false>; + type _CarvedOut = Expect<'pinned' extends RetiredListColumnKey ? false : true>; + // Control: `pinned` IS a `ListColumn` member, so its absence from the band + // is the carve-out doing work — not non-membership, which is `headerIcon`s + // reason. Two different routes to the same `false`, and they must not be + // conflated. + type _CtrlIsListColumnMember = Expect>; + // …and it is therefore still a member of both emit types. + type _StillOnColumn = Expect>; + type _StillOnDraft = Expect>; + expect(true).toBe(true); + }); + + /** + * The behaviour that must survive the removal: `ObjectGrid` writes + * `headerIcon` into the slot at three sites, so the emit types still have to + * ACCEPT it. Routed through a non-fresh value, like the other emit-boundary + * pins — a fresh literal would be refused or admitted by excess-property + * freshness, which is a different question from membership. + */ + it('the emit types still accept a written `headerIcon`', () => { + const emitted: { header: string; accessorKey: string; headerIcon?: unknown } = { + header: 'Name', + accessorKey: 'name', + headerIcon: null, + }; + const draft: ObjectGridColumnDraft = emitted as ObjectGridColumnDraft; + expect(draft.accessorKey).toBe('name'); + }); +}); From 83ad9ffecf85d7d1c00cf69285c83bb2b317032e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:26:38 +0000 Subject: [PATCH 2/2] chore(changeset): declare the plugin-grid holds cleanup as releasing nothing `ObjectGridColumnHolds` is not part of the package entry's exported surface (measured: 0 occurrences in `dist/index.d.ts`, control `ObjectGridColumnState` 1), and the two emit types are byte-identical without the member, so nothing published moves. Empty frontmatter is the gate's own explicit exemption for that case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6424-retire-headericon-hold.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/6424-retire-headericon-hold.md diff --git a/.changeset/6424-retire-headericon-hold.md b/.changeset/6424-retire-headericon-hold.md new file mode 100644 index 0000000000..01740df288 --- /dev/null +++ b/.changeset/6424-retire-headericon-hold.md @@ -0,0 +1,11 @@ +--- +--- + +Internal type cleanup in `plugin-grid`: `ObjectGridColumnHolds` no longer declares +`headerIcon`, which `TableColumn` has declared since objectui#6615, plus the docblock +corrections and pins that go with it. Nothing published moves — `ObjectGridColumnHolds` +is not part of the package entry's exported surface (measured: 0 occurrences in +`dist/index.d.ts`, control `ObjectGridColumnState` 1), and the two emit types are +byte-identical without the member (27 resolved members before and after, against a +positive control that removing `pinned` instead takes them to 26). No published +behaviour changes.