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
49 changes: 49 additions & 0 deletions .changeset/5453-retire-grid-column-wrap-forward.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 18 additions & 11 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -576,19 +582,21 @@ 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<keyof ListColumn, keyof TableColumn | 'wrap' | 'pinned'>;
export type RetiredListColumnKey = Exclude<keyof ListColumn, keyof TableColumn | 'pinned'>;

/** The undeclared-but-live keys this producer holds. See the docblock above. */
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. */
pinned?: 'left' | 'right';
/** HELD, objectui#5453 (blocked on objectui#5415) — inert, and not this card's to retire. */
wrap?: boolean;
}

/**
Expand Down Expand Up @@ -2105,7 +2113,6 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
...(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 }),
};
Expand Down
71 changes: 64 additions & 7 deletions packages/plugin-grid/src/__tests__/columnEmitBoundary-6004.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof ListColumn, keyof TableColumn | 'wrap' | 'pinned'>`, so a
* `Exclude<keyof ListColumn, keyof TableColumn | 'pinned'>`, 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
Expand All @@ -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<ObjectGridColumnDraft['wrap'] extends undefined ? true : false>;

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
Expand Down
Loading