Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 7db4a81

Browse files
os-steveclaude
andauthored
fix(plugin-kanban): key the leftover sweep the way the injection reads (objectui#8993) (#9044)
`bucketCardsIntoColumns` decided lane membership twice, with two key types. Injection reads `groups[col.id]`, and a property read coerces its key, so a lane `{ id: 1 }` correctly picks up the group stored under `'1'`. The leftover sweep (objectui#2792) then built its known-id `Set` from the RAW `col.id` and filtered `Object.keys(groups)` — always strings. `new Set([1]).has('1')` is `false`, so every record the injection had just placed was swept a second time into the trailing "Uncategorized" lane and the board rendered each card twice. The set now holds the same key spelling the property read uses. The injection side is untouched: making that read strict would break the coercion lanes depend on, and the string control could not catch it. A symbol is the one id a property read does not stringify, so it is kept as-is rather than pushed through `String()`, which throws on symbols. Pinned in laneIdCoercion-8993.test.ts: the reported numeric leg, the string control (green on both sides of the repair), the whole non-string class, the label→id path, a control proving the sweep still sweeps — a repair that simply deleted it would pass every other row and resurrect objectui#2792 — and the boundary rows showing the coercion mints no new collision. `@object-ui/types` carries prose only: the lane-id `describe()` and TSDoc stated the double-render as current behaviour. Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a174f3 commit 7db4a81

8 files changed

Lines changed: 250 additions & 16 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@object-ui/plugin-kanban': patch
3+
'@object-ui/types': patch
4+
---
5+
6+
Fix `bucketCardsIntoColumns` double-bucketing every record on a board whose lane
7+
ids are not strings (objectui#8993).
8+
9+
The function decided lane membership **twice**, with two key types:
10+
11+
- **injection** reads `groups[col.id]`, and a property read coerces its key — a
12+
lane `{ id: 1 }` correctly picks up the group stored under `'1'`;
13+
- **the leftover sweep** (objectui#2792) built its known-id `Set` from the RAW
14+
`col.id` and filtered `Object.keys(groups)`, which are always strings. Since
15+
`new Set([1]).has('1')` is `false`, every record the injection had just placed
16+
was swept a second time into the trailing "Uncategorized" lane.
17+
18+
⇒ A numeric-id board rendered **every card twice**. Silent by nature: the board
19+
draws, only the totals fail to reconcile.
20+
21+
The repair is on the sweep side — the set now holds the same key spelling the
22+
property read uses, so both decisions agree. ⛔ Not on the injection side:
23+
making that read strict would break the coercion lanes depend on, and the string
24+
control could not catch it. A symbol id is the one key a property read does not
25+
stringify, so it is kept as-is rather than pushed through `String()`, which
26+
throws on symbols; `Object.keys` never yields a symbol, so such a lane keeps the
27+
reading it has today.
28+
29+
**Visible change.** A board whose metadata already hits this — a picklist whose
30+
option `value` is a number, which reaches the renderer as a lane id through a
31+
path no schema guards — showed each card twice, once in its lane and once in
32+
"Uncategorized". It now shows each card once. The trailing lane still collects
33+
records whose group value matches no lane, which is objectui#2792's job and is
34+
pinned alongside the repair.
35+
36+
`@object-ui/types` carries prose only: the lane-id `describe()` and TSDoc that
37+
stated the double-render as current behaviour now state it as repaired. The
38+
authored `id` stays `string` — one declared lane-id type beats two.

‎content/docs/api/schema-reference.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
945945

946946
> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`.
947947
948-
> `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — a non-string lane id makes the board render every card twice, once in its lane and once in "Uncategorized" (objectui#8993). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name.
948+
> `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — the authored face keeps that narrowing, and since objectui#8993 a non-string lane id no longer renders every card twice: the bucketer's leftover sweep keys membership the way the injection already did (a lane `1` takes the group `'1'`). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name.
949949
>
950950
> ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array.
951951
>

‎content/docs/plugins/plugin-kanban.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ An undeclared lane key is accepted and dropped, not refused.
155155

156156
| Property | Type | Description |
157157
|----------|------|-------------|
158-
| `id` | string | Lane identifier, matched against the `groupBy` value. Strings only: a non-string id makes the board render every card twice, once in its lane and once in "Uncategorized" (objectui#8993) |
158+
| `id` | string | Lane identifier, matched against the `groupBy` value. Declare a string; since objectui#8993 a non-string id is matched by its string spelling (a lane `1` takes the group `'1'`) instead of rendering every such card twice |
159159
| `title` | string | Lane title, localized against the `groupBy` picklist's option labels |
160160
| `cards` | KanbanCard[] | Cards this lane carries — **optional**, and a static board's option; an object-bound board's cards arrive from the record source |
161161
| `limit` | number | Max cards allowed (WIP limit). Never reaches the query — the fetch window is the board's `limit` |
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#8993 — `bucketCardsIntoColumns` decides lane membership TWICE, and
11+
* before this pin the two decisions used different key types.
12+
*
13+
* ## The mechanism
14+
*
15+
* - **Injection** reads `groups[col.id]`. A property read coerces its key, so
16+
* a lane `{ id: 1 }` correctly picks up the group stored under `'1'`.
17+
* - **The leftover sweep** (#2792) built its known-id Set from the RAW value
18+
* and filtered `Object.keys(groups)` — which are ALWAYS strings. Since
19+
* `new Set([1]).has('1')` is `false`, every record the injection had just
20+
* placed was swept a second time into the trailing "Uncategorized" lane.
21+
*
22+
* ⇒ A numeric-id board rendered every card twice. Silent: the board draws, the
23+
* totals just do not reconcile, which is why the counting row below exists.
24+
*
25+
* ## What each row is for
26+
*
27+
* 1. NUMERIC — the reported reading. Was `'1:r1, 2:r2, __uncolumned__:r1+r2'`.
28+
* 2. STRING CONTROL — a live control, green on BOTH sides of the repair. It is
29+
* what makes row 1 a statement about the id TYPE rather than about bucketing
30+
* in general; if it ever reddens, the repair went the wrong way.
31+
* 3. THE CLASS — the defect is "the id is not a string", not "the id is a
32+
* number", so every non-string id type the function can be handed is pinned,
33+
* including the falsy ones (`0`, `false`) that also skip the label map.
34+
* 4. THE LABEL PATH — a record can reach `groups` through the label→id map,
35+
* which stores the RAW id as its VALUE. That path double-bucketed too, so
36+
* one row keeps it honest; keying only the direct path would leave it open.
37+
* 5. THE SWEEP STILL SWEEPS — the control that separates a repair from a
38+
* deletion. Rows 1-4 would all pass if the sweep were simply removed, and
39+
* that would resurrect #2792 (records silently dropped). An unmatched record
40+
* must still surface in the trailing lane.
41+
* 6. NO NEW COLLISION — coercing a key can MINT a collision where there was
42+
* none, so the boundary is asserted rather than argued: `String(null)` is
43+
* `'null'` and `String(undefined)` is `'undefined'`, and neither may swallow
44+
* the empty key that a null/absent VALUE produces; a lane declared with the
45+
* `KANBAN_UNCOLUMNED_ID` spelling keeps its existing reading; and a symbol
46+
* id — the one key a property read does NOT stringify — must keep behaving
47+
* as it does today instead of throwing inside `String()`.
48+
*
49+
* ⛔ The repair belongs on the SWEEP side. Making the injection strict would
50+
* pass rows 1-2 while breaking the coercion lanes depend on, and the string
51+
* control could not catch it.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index';
56+
57+
/** `id:card+card` per lane, in lane order — the whole board in one string. */
58+
const summarise = (lanes: Array<any>): string =>
59+
lanes
60+
.map((l: any) => `${String(l.id)}:${(l.cards || []).map((c: any) => c.id).join('+')}`)
61+
.join(', ');
62+
63+
const bucket = (columns: Array<any>, data: Array<any>): string =>
64+
summarise(bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized'));
65+
66+
/** Every card id the board renders, across every lane, duplicates included. */
67+
const renderedCardIds = (columns: Array<any>, data: Array<any>): string[] =>
68+
bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized').flatMap(
69+
(l: any) => (l.cards || []).map((c: any) => c.id),
70+
);
71+
72+
describe('bucketCardsIntoColumns — lane id key coercion (objectui#8993)', () => {
73+
it('row 1 — NUMERIC lane ids bucket each record exactly once', () => {
74+
expect(
75+
bucket(
76+
[
77+
{ id: 1, title: 'One' },
78+
{ id: 2, title: 'Two' },
79+
],
80+
[
81+
{ id: 'r1', status: 1 },
82+
{ id: 'r2', status: '2' },
83+
],
84+
),
85+
).toBe('1:r1, 2:r2');
86+
});
87+
88+
it('row 2 — STRING control: unchanged, green on both sides of the repair', () => {
89+
expect(bucket([{ id: 'one', title: 'One' }], [{ id: 'r1', status: 'one' }])).toBe('one:r1');
90+
});
91+
92+
describe('row 3 — the class is "id is not a string", not "id is a number"', () => {
93+
const cases: Array<{ name: string; id: unknown; value: unknown; lane: string }> = [
94+
{ name: 'number', id: 1, value: 1, lane: '1' },
95+
{ name: 'zero (falsy, so the label map skips it)', id: 0, value: 0, lane: '0' },
96+
{ name: 'boolean true', id: true, value: true, lane: 'true' },
97+
{ name: 'boolean false (falsy)', id: false, value: false, lane: 'false' },
98+
{ name: 'null', id: null, value: 'null', lane: 'null' },
99+
{ name: 'undefined (a lane with no id at all)', id: undefined, value: 'undefined', lane: 'undefined' },
100+
{ name: 'NaN', id: NaN, value: NaN, lane: 'NaN' },
101+
{ name: 'array', id: [1, 2], value: '1,2', lane: '1,2' },
102+
{ name: 'plain object', id: { a: 1 }, value: '[object Object]', lane: '[object Object]' },
103+
];
104+
105+
for (const { name, id, value, lane } of cases) {
106+
it(`${name} — one lane, one card, no trailing "Uncategorized"`, () => {
107+
const columns = [{ id, title: 'Lane' }];
108+
const data = [{ id: 'r1', status: value }];
109+
expect(bucket(columns, data)).toBe(`${lane}:r1`);
110+
// The counting row: the visible card total must reconcile with the
111+
// record count. This is the user-visible symptom, stated directly.
112+
expect(renderedCardIds(columns, data)).toEqual(['r1']);
113+
});
114+
}
115+
});
116+
117+
it('row 4 — the label→id path is keyed the same way (it stores the RAW id)', () => {
118+
// 'One' matches the lane's TITLE, so the group key comes out of
119+
// `labelToColumnId`, whose stored VALUE is the raw numeric id.
120+
expect(bucket([{ id: 1, title: 'One' }], [{ id: 'r1', status: 'One' }])).toBe('1:r1');
121+
});
122+
123+
it('row 5 — the sweep still sweeps: an unmatched record still surfaces (#2792)', () => {
124+
// A repair that simply deleted the sweep would pass every row above and
125+
// silently drop this record — the defect #2792 closed.
126+
expect(
127+
bucket(
128+
[
129+
{ id: 1, title: 'One' },
130+
{ id: 2, title: 'Two' },
131+
],
132+
[
133+
{ id: 'r1', status: 1 },
134+
{ id: 'r9', status: 'retired_option' },
135+
],
136+
),
137+
).toBe(`1:r1, 2:, ${KANBAN_UNCOLUMNED_ID}:r9`);
138+
});
139+
140+
describe('row 6 — the coercion mints no new collision', () => {
141+
it("a null lane id does not swallow the empty key a null VALUE produces", () => {
142+
// `String(null)` is 'null', but a record whose group VALUE is null is
143+
// keyed '' (`String(item[groupBy] ?? '')`). The two must stay apart.
144+
expect(bucket([{ id: null, title: 'N' }], [{ id: 'r1', status: null }])).toBe(
145+
`null:, ${KANBAN_UNCOLUMNED_ID}:r1`,
146+
);
147+
});
148+
149+
it("an undefined lane id does not swallow the empty key an ABSENT value produces", () => {
150+
expect(bucket([{ title: 'U' }], [{ id: 'r1' }])).toBe(
151+
`undefined:, ${KANBAN_UNCOLUMNED_ID}:r1`,
152+
);
153+
});
154+
155+
it('a lane declared with the sentinel spelling keeps its own records', () => {
156+
const lanes = bucketCardsIntoColumns(
157+
[{ id: KANBAN_UNCOLUMNED_ID, title: 'Declared' }],
158+
[{ id: 'r1', status: KANBAN_UNCOLUMNED_ID }],
159+
'status',
160+
undefined,
161+
'Uncategorized',
162+
);
163+
expect(summarise(lanes)).toBe(`${KANBAN_UNCOLUMNED_ID}:r1`);
164+
expect(lanes).toHaveLength(1); // no second lane under the same id
165+
});
166+
167+
it("a symbol lane id keeps today's reading instead of throwing", () => {
168+
// A symbol is the one key a property read does NOT stringify, so it is
169+
// never pushed through `String()` (which throws on symbols). `Object.keys`
170+
// never yields a symbol, so such a lane matches nothing — exactly as
171+
// before this repair.
172+
const columns = [{ id: Symbol('lane'), title: 'S' }];
173+
const data = [{ id: 'r1', status: 'x' }];
174+
expect(() => bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized'))
175+
.not.toThrow();
176+
expect(renderedCardIds(columns, data)).toEqual(['r1']);
177+
expect(bucket(columns, data)).toBe(`Symbol(lane):, ${KANBAN_UNCOLUMNED_ID}:r1`);
178+
});
179+
});
180+
});

‎packages/plugin-kanban/src/index.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,20 @@ export function bucketCardsIntoColumns(
9797
// still counts. Surface them in a trailing "Uncategorized" lane; dragging
9898
// one out to a real column repairs its status (the drag handler refuses to
9999
// persist a move INTO here).
100-
const knownIds = new Set(columns.map((col: any) => col.id));
100+
// ⚠️ Key this membership test the way the injection above keys its READ.
101+
// `groups[col.id]` is a property read, so it coerces the id: a lane
102+
// `{ id: 1 }` correctly picks up the group stored under `'1'`, and every key
103+
// `Object.keys(groups)` yields is a string. A Set built from the RAW id
104+
// therefore answers `new Set([1]).has('1') === false` and sweeps the very
105+
// records the injection already took — the board renders each of them twice,
106+
// once in its lane and once in "Uncategorized" (objectui#8993). Membership is
107+
// decided twice here, so both decisions must use the same key spelling.
108+
// A symbol is the one id a property read does NOT stringify, so it is kept
109+
// as-is rather than pushed through `String()` (which throws on symbols):
110+
// `Object.keys` never yields a symbol, so such a lane keeps today's reading.
111+
const knownIds = new Set<PropertyKey>(
112+
columns.map((col: any) => (typeof col.id === 'symbol' ? col.id : String(col.id))),
113+
);
101114
const uncolumnedCards = Object.keys(groups)
102115
.filter((key) => !knownIds.has(key))
103116
.flatMap((key) => groups[key]);

‎packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@
6363
* - **a NUMERIC lane id.** The first cut admitted one, justified by "the
6464
* renderer coerces with `String(col.id)`". That is true at the i18n lookup
6565
* in `localizeColumn` and FALSE where lane membership is decided: the
66-
* bucketer builds `knownIds` from the RAW `col.id` and compares it with
66+
* bucketer built `knownIds` from the RAW `col.id` and compared it with
6767
* `Object.keys(groups)`, which are strings. Lanes `{ id: 1 }, { id: 2 }`
68-
* with records `status: 1` / `status: '2'` come back
68+
* with records `status: 1` / `status: '2'` came back
6969
* `1:r1, 2:r2, __uncolumned__:r1+r2` — every card rendered TWICE — against
70-
* a clean `one:r1` string control. Carrier for the renderer defect:
71-
* objectui#8993. `KanbanColumn.id` and its mirror are `string`, and the
70+
* a clean `one:r1` string control. objectui#8993 repaired that sweep; the
71+
* refusal stays because one declared lane-id type beats two, not because
72+
* the renderer is still broken. `KanbanColumn.id` and its mirror are `string`, and the
7273
* protocol names no type, so refusing it here is not a narrowing below the
7374
* protocol.
7475
* - **a MIXED array.** `columns` is a UNION OF TWO ARRAYS, not an array of a
@@ -208,7 +209,7 @@ const REFUSED: Array<readonly [string, unknown]> = [
208209
// before this card AND under objectui#8913's first cut, which is why they
209210
// belong here with the same control as every other row: the failure they
210211
// guard is a declaration blessing a shape the renderer mishandles.
211-
['⭐ a NUMERIC lane id — the bucketer renders every such card twice (objectui#8993)', [{ id: 1, title: 'Stage one' }]],
212+
['⭐ a NUMERIC lane id — the bucketer rendered every such card twice until objectui#8993', [{ id: 1, title: 'Stage one' }]],
212213
['⭐ a MIXED array, string first — the renderer ignores the whole list', ['todo', { id: 'done', title: 'Done' }]],
213214
['⭐ a MIXED array, object first — the renderer emits a blank lane and mis-buckets', [{ id: 'done', title: 'Done' }, 'todo']],
214215
];

‎packages/types/src/objectql.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3185,17 +3185,19 @@ export interface ObjectKanbanSchema extends BaseSchema {
31853185
*
31863186
* STRING only. objectui#8913's first cut admitted a number here on the
31873187
* reading that "the renderer coerces with `String(col.id)`" — TRUE at
3188-
* the i18n lookup in `localizeColumn`, and FALSE at the site that
3189-
* decides lane membership: `bucketCardsIntoColumns` builds its
3190-
* `knownIds` set from the RAW `col.id` and compares it against
3188+
* the i18n lookup in `localizeColumn`, and FALSE, at the time, where
3189+
* lane membership is decided: `bucketCardsIntoColumns` built its
3190+
* `knownIds` set from the RAW `col.id` and compared it against
31913191
* `Object.keys(groups)`, which are always strings. Measured by calling
31923192
* the real function with lanes `{ id: 1 }, { id: 2 }` and records
31933193
* `status: 1` / `status: '2'`: `1:r1, 2:r2, __uncolumned__:r1+r2` —
31943194
* every card rendered TWICE, once in its lane and once in
3195-
* "Uncategorized"; the string control is a clean `one:r1`. The retired
3196-
* arm ({@link KanbanColumn.id}) and its mirror are `string` too, and the
3197-
* protocol names no type. Carrier for the bucketer defect itself:
3198-
* objectui#8993.
3195+
* "Uncategorized"; the string control was a clean `one:r1`.
3196+
* objectui#8993 repaired that sweep, so the two decisions now use one
3197+
* key spelling — the narrowing stays because the retired arm
3198+
* ({@link KanbanColumn.id}) and its mirror are `string` too and one
3199+
* declared type per lane id beats two, not because the renderer is
3200+
* still broken.
31993201
*/
32003202
id: string;
32013203
/** Lane heading; localized against the `groupBy` picklist's option labels. */

0 commit comments

Comments
 (0)