From fc721d2e6c5dfd0eb453e81ddc69f5e68084c87a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:22:58 +0000 Subject: [PATCH] refactor(plugin-grid): retire the dead per-column `wrap` forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generateColumns()` copied a per-column `wrap` onto the column object handed to `data-table`, and `data-table.tsx` never read it. Measured on this ref with comments stripped: a column-level `wrap` scores 0 reads, against 34 for `accessorKey`, 5 for `align`, 4 for `header`, 4 for `className` and 2 for `fitContent` in the same query shape. There is also nothing for it to switch on. `data-table`'s cell wrapper is a two-way `isFit ? 'w-full whitespace-nowrap' : 'truncate w-full'`, with a `title` tooltip as the only concession to overflow, and the file does not read `density` or `rowHeight` at all — no clamp, no expand, no wrap affordance. Unlike `pinned` (also unread by `data-table`, but consumed by this file's own reorder pass and re-expressed as a sticky `className`), `wrap` had no second road to any consumer, so enforce-or-remove resolves to remove. Retired rather than merely deleted: `wrap` is no longer carved out of `RetiredListColumnKey`'s Exclude, so the derived tombstone band now types it `never` and re-adding the forward is a compile error naming the key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../5453-retire-grid-column-wrap-forward.md | 49 +++++++++++++ packages/plugin-grid/src/ObjectGrid.tsx | 29 +++++--- .../__tests__/columnEmitBoundary-6004.test.ts | 71 +++++++++++++++++-- 3 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 .changeset/5453-retire-grid-column-wrap-forward.md diff --git a/.changeset/5453-retire-grid-column-wrap-forward.md b/.changeset/5453-retire-grid-column-wrap-forward.md new file mode 100644 index 0000000000..c3c6010431 --- /dev/null +++ b/.changeset/5453-retire-grid-column-wrap-forward.md @@ -0,0 +1,49 @@ +--- +'@object-ui/plugin-grid': minor +--- + +Retire the dead per-column `wrap` forward from `ObjectGrid`'s data-table emit (objectui#5453, +ADR-0049 enforce-or-remove). + +`generateColumns()` copied a per-column `wrap` onto every column object handed to +`data-table`, and `packages/components`' `data-table.tsx` never read it. Measured on the +current ref rather than inherited from the card, with comments stripped so prose mentions +cannot be counted as reads: a column-level `wrap` scores **0**, against `accessorKey` 34, +`align` 5, `header` 4, `className` 4, `width` 8 and `fitContent` 2 in the same query shape. +Those sibling counts are the positive control — the search style does find the keys that are +genuinely consumed, so the zero is a measurement and not a mis-aimed grep. The raw string +`wrap` does occur in that file; every occurrence is `flex-wrap`, `whitespace-nowrap`, or a +variable named `wrapper`. + +The implement leg was ruled out by the same measurement rather than by preference. `wrap: true` +would have to drop a `truncate`, which presupposes the renderer has somewhere to put a second +line — and it does not. `data-table`'s cell wrapper is a two-way switch, +`isFit ? 'w-full whitespace-nowrap' : 'truncate w-full'`, with a native `title` tooltip as the +only concession to overflow; the file does not read `density` or `rowHeight` at all. There is +no clamp, no expand, no line-clamp and no multi-line affordance for a per-column `wrap` to turn +on, so the enforce-or-remove default applies. + +Removing the forward rather than declaring the key follows from `wrap` having **no second road +to a consumer**. That check is what separates this verdict from `pinned`, which `data-table` +also never reads and which is nonetheless kept: `ObjectGrid`'s own reorder pass consumes +`pinned` before the array reaches the slot and re-expresses it as the sticky `className` the +renderer does read. `wrap` had no such pass anywhere. + +**Retired, not merely deleted.** `wrap` is no longer carved out of `RetiredListColumnKey`'s +`Exclude`, so the derived tombstone band that objectui#6461 installed on this producer now types +it `never`. Re-adding `...(col.wrap !== undefined && { wrap: col.wrap })` is a compile error +naming `wrap` — otherwise "retired" is just a deleted line the next edit can put back for free. +Deriving the band from the authored `ListColumn` is also why this needed no hand-maintained +list: taking the carve-out out was the whole edit. + +**Breaking for TypeScript consumers of `@object-ui/plugin-grid`'s exported emit types only** +(`ObjectGridColumnHolds` loses its `wrap` member; `ObjectGridColumnDraft['wrap']` becomes +`never`). Marked `minor` per this repo's version-alignment rule, which reserves `major` for +following `@objectstack` across a major — the same classification the `MobileOverrides` +retirement used. **Runtime behaviour is unchanged**: an authored `wrap` did nothing before and +does nothing now. What changed is that the code no longer implies `data-table` might consume it. + +⚠️ The **authorable** spelling is untouched and still declared: `@objectstack/spec`'s +`ListColumn.wrap`, which `packages/react`'s spec-bridge still forwards into the grid schema. +Whether that spec property should keep being declared with no renderer anywhere is a spec-side +enforce-or-remove question, filed separately rather than settled from inside this renderer. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 1e2c26da29..a4072b0e30 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -552,11 +552,17 @@ function normalizeColumns( * 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 * `pinned` itself, and does not need to. - * - `wrap` — HELD, and deliberately NOT retired here. Nothing anywhere reads - * it, so this card's rule would retire it — but objectui#5453 already owns - * that key and is `pm:blocked` on objectui#5415, whose outcome decides - * implement-vs-remove. Retiring it here would settle a blocked card from - * the outside. It is declared, inert, and stays exactly as it was. + * - `wrap` — RETIRED (objectui#5453, 2026-08-28). Held here only because that + * card was `pm:blocked` at the time; triage unblocked it and it took the + * measurement this hold was waiting for. `data-table.tsx` offers NO clamp / + * expand / wrap affordance for long cell text: its cell wrapper is a + * two-way switch, `isFit ? 'w-full whitespace-nowrap' : 'truncate w-full'`, + * with a `title` tooltip as the only concession to overflow, and it does + * not read `density` or `rowHeight` at all. So there is nothing for a + * per-column `wrap` to turn on, and the enforce-or-remove default applies: + * the forward is deleted rather than declared-and-maintained. ⚠️ Unlike + * `pinned`, `wrap` had NO second road to a consumer — that is the check + * this card's rule demands before retiring, and it came back empty. * - `options` — RETIRED (see the enrichment pass below). * - `type` — not adjudicated here; objectui#5853 owns its VALUE set and its * fold still stands. It is the one member whose vocabulary differs between @@ -576,10 +582,14 @@ function normalizeColumns( * * `ListColumn` is the right derivation source because it is where this * producer's drift comes from: every key the emit could wrongly grow is a key - * the author wrote on the input and someone forwarded. `wrap` and `pinned` are - * the two that already escaped, both adjudicated HELD above. + * the author wrote on the input and someone forwarded. `pinned` is the one that + * escaped and stayed — adjudicated HELD above. `wrap` escaped too and has since + * been RETIRED (objectui#5453), so it is no longer carved out of the Exclude + * and the derived band now tombstones it: re-adding the forward is a compile + * error naming `wrap`, which is the whole point of retiring it here rather than + * just deleting a line the next edit could put back for free. */ -export type RetiredListColumnKey = Exclude; +export type RetiredListColumnKey = Exclude; /** The undeclared-but-live keys this producer holds. See the docblock above. */ export interface ObjectGridColumnHolds { @@ -587,8 +597,6 @@ export interface ObjectGridColumnHolds { headerIcon?: React.ReactNode; /** HELD — consumed by this file's own reorder pass before the array reaches the slot. */ pinned?: 'left' | 'right'; - /** HELD, objectui#5453 (blocked on objectui#5415) — inert, and not this card's to retire. */ - wrap?: boolean; } /** @@ -2105,7 +2113,6 @@ export const ObjectGrid: React.FC = ({ ...(inferredAlign && { align: inferredAlign }), sortable: col.sortable !== false, ...(col.resizable !== undefined && { resizable: col.resizable }), - ...(col.wrap !== undefined && { wrap: col.wrap }), ...(cellRenderer && { cell: cellRenderer }), ...(col.pinned && { pinned: col.pinned }), }; diff --git a/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts b/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts index 1d1bc3cb3e..f40ed4b8a0 100644 --- a/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts +++ b/packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts @@ -120,7 +120,7 @@ describe('objectui#6004 — the emit boundary is an instrument, not a decoration /** * ⭐ DERIVED, NEVER HAND-LISTED. The tombstone set is - * `Exclude`, so a + * `Exclude`, so a * key ADDED to the spec's `ListColumn` tomorrow is refused by default and has * to be adjudicated to escape. These two pin both halves of that rule: a * `ListColumn` key that `TableColumn` does not declare is `never` on the @@ -139,17 +139,74 @@ describe('objectui#6004 — the emit boundary is an instrument, not a decoration }); /** - * The three HELD keys. Each is undeclared by `TableColumn` and each has a - * measured live reader (or, for `wrap`, an open card that owns it), so the - * emit type must ACCEPT them — a tombstone set that swallowed these would be - * a behaviour change wearing a type change's clothes. + * The HELD keys — the two `ObjectGridColumnHolds` still declares, which the + * emit type must therefore 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. + * + * ⚠️ 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 + * was blocked, so it had "an open card" standing in for a measurement. That + * card has since taken the measurement and RETIRED the key, so it moved to + * the tombstone pin below. The lesson worth keeping: a hold justified by an + * 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 three held keys — headerIcon, pinned, wrap', () => { - const held = { header: 'H', accessorKey: 'a', headerIcon: null, pinned: 'left' as const, wrap: true }; + it('accepts the two held keys — headerIcon, pinned', () => { + const held = { header: 'H', accessorKey: 'a', headerIcon: null, pinned: 'left' as const }; const accepted: ObjectGridColumnDraft = held; expect(accepted.pinned).toBe('left'); }); + /** + * ⭐ THE RETIRED KEY (`wrap`, objectui#5453). + * + * `ObjectGrid` forwarded a per-column `wrap` into the `TableColumn[]` slot and + * `data-table.tsx` never read it. Measured on the current ref, comments + * stripped, in the same query shape that finds the keys that ARE consumed — + * `accessorKey` 34, `align` 5, `header` 4, `className` 4, `fitContent` 2 — + * a column-level `wrap` scores 0. The raw string `wrap` does occur in that + * file, but every occurrence is `flex-wrap`, `whitespace-nowrap` or a + * variable named `wrapper`; the sibling counts are the positive control that + * makes the zero a measurement rather than a mis-aimed grep. + * + * Nor is there anything for it to switch on: `data-table`'s cell wrapper is a + * two-way `isFit ? 'w-full whitespace-nowrap' : 'truncate w-full'`, and the + * file does not read `density` or `rowHeight` at all. No clamp, no expand, no + * wrap affordance ⇒ enforce-or-remove resolves to remove. + * + * ⚠️ Unlike `pinned` — which `data-table` also never reads, and which is HELD + * anyway because THIS file consumes it first and re-expresses it as a sticky + * `className` — `wrap` had no second road to any consumer. That check is what + * separates the two verdicts, and it is the check the emit rule demands + * before retiring. + * + * Refused by the DERIVED band: `wrap` is a `ListColumn` key that + * `TableColumn` does not declare, and it is no longer carved out of + * `RetiredListColumnKey`'s Exclude. Routed through a non-fresh value like the + * other tombstone pins, so the tombstone is the single cause — freshness + * cannot refuse a non-fresh source. + */ + it('the emit type refuses the retired `wrap` key', () => { + // The derived band covers it, so the member itself is `never`. + type _WrapTombstoned = Expect; + + const emitted: { header: string; accessorKey: string; wrap?: boolean } = + { header: 'Notes', accessorKey: 'notes', wrap: true }; + // @ts-expect-error objectui#5453 — `wrap` retired from this producer's emit, refused by the derived tombstone band. + const refused: ObjectGridColumnDraft = emitted; + expect(refused.accessorKey).toBe('notes'); + }); + /** * ⭐ THE RETIRED KEY (`options`). Nothing on either side of the seam reads a * column-level `options`: `data-table` has no such read, and this component's