Skip to content

Commit 708922f

Browse files
fix(plugin-kanban): a lane count waits for the records, so a board in flight no longer reads 0 on every lane (#31463)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPCbLjakXLAmCo2Z8MK5yy7a)_ Closes #30431 ## What changes A kanban lane no longer states a count before the board's records have settled. Every header that writes one (the flat board's badge, the swimlane layout's column titles and lane rows) shows a small pending marker instead, the lane carries `aria-busy` meanwhile, and the lane total waits too. The signal is `recordsSettled`, the one the board's "No cards" state already waits on (objectui#8827); no new string, no spec change. - `ui/packages/plugin-kanban/src/KanbanImpl.tsx`: `LaneCountPending`, and the three count sites read `useKanbanRecordsSettled()`. - `ui/packages/plugin-kanban/src/KanbanRecordsSettled.ts`: one paragraph on the context's doc saying the counts wait on it too. - Tests: a new describe in `ObjectKanban.lanePaging-12619.test.tsx` (board level) and one in `app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx` (the real `ListView` over the real board). ## Why Reproduced on cloud's hosted-shaped two-plane rig (`scripts/dev-local/run-stack.sh --prod-like --with-ui`: control plane on :4000, multi-tenant runtime on :5050; cloud `ac69aac`, framework pin `98583cd6`, console built from `ui/` at the pin; zh-CN). The card's 客服工单 package (12 tickets, 2 / 4 / 2 / 3 / 1) was built through the env's MCP door (`apply_blueprint`, drafts only), published in Studio › Changes, and the board 工单看板 opened from the app nav. - Lanes answering at local speed: the first open is correct (Studio publish then in-app nav, a fresh tab, and nav concurrent with the publish). - 300 ms of emulated network latency: footer 「12 条记录」 over lanes 待处理 0 / 处理中 0 / 等待客户 0 / 已解决 0, then the right counts. - Lane answers held for 6 s: the same state every time. Network order: the list's own count (`top=0`, total 12) and window (12 rows) answer first, so the footer is drawn; the board's six lane queries (one per lane plus the records in no lane) leave after them and answer later. In between, every lane reads 0. After: 2 / 4 / 2 / 3. The cause is in the board. Under a list view the footer and the lanes answer from different queries. Before the lane answers land, `lanePages` is `null` and the board's rows are `[]`, so each header counted 0 cards. The empty state was already withheld for exactly this window (objectui#8827), so the lanes drew no "No cards", but the count badge still claimed `0`. A pending lane and an empty lane were the same pixels. What staging differs in, as far as the rig can say: how long the first lane queries take right after publish (a cold env kernel, a remote database, each lane query carrying its `populate`). That is an inference; staging was not measured here. If staging's lane queries in fact answered 0 rows rather than answering slowly, this change does not fix that. It does make the two tell apart: after it, a lane reading `0` is a lane whose query answered 0. ## Verification - `plugin-kanban`: 85 files, 554 tests green; `type-check` (src and tests) clean. - Tests outside the package that mount the real board (app-shell, plugin-list, plugin-form, plugin-calendar, i18n, console): 10 files, 229 tests green. - `eslint` on the changed files with the root config and `--no-inline-config`: 0 errors. - `check:empty-changeset`, `check:changeset-fixed`, `check:cross-package-test-inputs`: green. - Ablations with `scripts/ablation-replace.mjs`, each restored to the HEAD blob: - `const countPending = !useKanbanRecordsSettled()` → `const countPending = false`: the board test and the list-view test go red with `expected '0' to be ''`; every other test stays green. - the swimlane column-title gate → the unconditional count: the swimlane test goes red with `expected 'Open(0)Won(0)Lost(0)' not to match /\(\d+\+?\)/`. - The reverse test pins that a lane that really holds nothing still reads `0` once its query answers, so withholding the count forever cannot pass. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Run on the changed files with the root config; CI runs it whole.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [ ] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not applicable: `packages/spec` is not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3Bt8hvKRjPf7fmpY12X Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c38025 commit 708922f

5 files changed

Lines changed: 250 additions & 11 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/plugin-kanban': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
A kanban lane no longer reads `0` before its records have arrived. Every lane header that writes a count (the flat board's badge, the swimlane layout's column titles and lane rows) shows a small pending marker instead until the board's records settle, and the lane carries `aria-busy` meanwhile; the lane total waits the same way. Before this, a board opened right after publish under a list view showed the list's footer 「12 条记录」 over lanes reading 0 / 0 / 0 / 0 for as long as its lane queries were in flight. A lane that really holds nothing still reads `0` once its query answers. The signal is the one the board's "No cards" state already waits on (objectui#8827); no new string.

‎ui/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
*/
3636
import React from 'react';
3737
import { describe, it, expect, vi, afterEach } from 'vitest';
38-
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
38+
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
3939
import '@testing-library/jest-dom';
4040
import { ValueDataSource } from '@object-ui/core';
4141
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
@@ -166,3 +166,93 @@ describe('a kanban under a list view draws every record of the view in its colum
166166
expect(within(card).queryByText('Close Date')).not.toBeInTheDocument();
167167
});
168168
});
169+
170+
/**
171+
* objectstack#30431 — under a list view the footer and the lanes answer from
172+
* different queries: the list asks its own window and count, the board asks one
173+
* query per lane. Walked on cloud's two-plane stack with the card's 客服工单
174+
* package (12 tickets, 2 / 4 / 2 / 3 / 1), a board opened right after publish
175+
* read footer 「12 条记录」 over lanes 0 / 0 / 0 / 0 while its lane queries were
176+
* in flight. Here the lane queries are held open and the list's are not, which
177+
* is that ordering.
178+
*/
179+
describe('the board under a list view claims no lane count before its lanes answer (objectstack#30431)', () => {
180+
const TICKET = 'helpdesk_ticket';
181+
const STATUSES = [
182+
['new', '待处理', 2],
183+
['in_progress', '处理中', 4],
184+
['waiting_customer', '等待客户', 2],
185+
['resolved', '已解决', 3],
186+
['closed', '已关闭', 1],
187+
] as const;
188+
const TICKETS: Row[] = STATUSES.flatMap(([status, , count]) =>
189+
Array.from({ length: count }, (_, i) => ({ id: `${status}-${i}`, subject: `${status} ticket ${i}`, status })),
190+
);
191+
const TICKET_DEF = {
192+
name: TICKET,
193+
label: '工单',
194+
fields: {
195+
subject: { type: 'text', label: '标题' },
196+
status: { type: 'select', label: '状态', options: STATUSES.map(([value, label]) => ({ value, label })) },
197+
},
198+
};
199+
const BOARD_VIEW = {
200+
type: 'list-view',
201+
objectName: TICKET,
202+
viewType: 'kanban',
203+
columns: [{ field: 'subject' }, { field: 'status' }],
204+
kanban: { groupByField: 'status', titleField: 'subject' },
205+
};
206+
207+
/** A lane query is the one that filters on the lane field; the list's never does. */
208+
const isLaneQuery = (params: any) => JSON.stringify(params?.$filter ?? null).includes('"status"');
209+
210+
function heldLanes() {
211+
const store = new ValueDataSource<Row>({ items: TICKETS });
212+
let release!: () => void;
213+
const gate = new Promise<void>((r) => {
214+
release = r;
215+
});
216+
const ds = {
217+
find: vi.fn(async (object: string, params: any) => {
218+
if (isLaneQuery(params)) await gate;
219+
return store.find(object, params);
220+
}),
221+
findOne: vi.fn(),
222+
create: vi.fn(),
223+
update: vi.fn(),
224+
delete: vi.fn(),
225+
getObjectSchema: vi.fn(async () => TICKET_DEF),
226+
} as any;
227+
return { ds, release: () => act(async () => release()) };
228+
}
229+
230+
/** The count badge beside a lane title. */
231+
const laneBadge = (title: string) => within(lane(title)).getByRole('heading').parentElement!.querySelector('span')!;
232+
233+
it('the footer may read 12 while the lanes wait, but no lane reads 0 until it has answered', async () => {
234+
const { ds, release } = heldLanes();
235+
mountBoard(ds, BOARD_VIEW);
236+
237+
// RIG SELF-CHECK — the state the card reported: the list's count is on
238+
// screen and every lane query has left without an answer.
239+
await waitFor(() => expect(screen.getByTestId('record-count-bar')).toHaveTextContent('12 records'));
240+
await waitFor(() => expect(ds.find.mock.calls.filter((call: any[]) => isLaneQuery(call[1]))).toHaveLength(STATUSES.length + 1));
241+
expect(cards()).toHaveLength(0);
242+
243+
for (const [, title] of STATUSES) {
244+
// RED before the fix: '0' on every lane, under a footer reading 12.
245+
expect(laneBadge(title).textContent?.trim()).toBe('');
246+
expect(lane(title)).toHaveAttribute('aria-busy', 'true');
247+
}
248+
249+
await release();
250+
251+
await waitFor(() => expect(cards()).toHaveLength(12));
252+
for (const [, title, count] of STATUSES) {
253+
expect(laneBadge(title).textContent?.trim()).toBe(String(count));
254+
expect(lane(title)).not.toHaveAttribute('aria-busy');
255+
}
256+
expect(screen.getByTestId('record-count-bar')).toHaveTextContent('12 records');
257+
});
258+
});

‎ui/packages/plugin-kanban/src/KanbanImpl.tsx‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,35 @@ function laneCountLabel(count: number, countsAreWindowed?: boolean): string {
675675
return countsAreWindowed ? `${count}+` : String(count)
676676
}
677677

678+
/**
679+
* What a lane header shows IN PLACE OF its count while the board's records
680+
* have not settled (objectstack#30431).
681+
*
682+
* A lane count is a claim about the data, the same claim "No cards" makes, and
683+
* before the records settle the only number this board holds is the length of
684+
* an array nothing has filled yet. Drawn, that number is `0` on every lane.
685+
* Measured on cloud's two-plane stack: a board opened right after publish,
686+
* under a list view whose own count had already answered, showed footer
687+
* 「12 条记录」 over lanes reading 0 / 0 / 0 / 0 for as long as its lane
688+
* queries were in flight, then 2 / 4 / 2 / 3. "Pending" and "this lane holds
689+
* nothing" were the same pixels.
690+
*
691+
* So the count waits for what the empty state waits for — `recordsSettled`
692+
* (objectui#8827) — at every header that writes one. The badge itself stays,
693+
* at its size, so the header does not reflow when the number lands. The
694+
* marker is decorative (`aria-hidden`); the lane it sits in carries
695+
* `aria-busy`, which is what assistive technology reads. No new string.
696+
*/
697+
function LaneCountPending() {
698+
return (
699+
<span
700+
aria-hidden="true"
701+
data-kanban-lane-count-pending=""
702+
className="inline-block h-2 w-2.5 rounded-sm bg-muted-foreground/30 animate-pulse"
703+
/>
704+
)
705+
}
706+
678707
/**
679708
* A column's total of the view's `summarizeField`, painted beside its count
680709
* (objectui#11629). Renders nothing when the board declares no
@@ -796,9 +825,13 @@ function KanbanColumnView({
796825
const lanePaging = useKanbanLanePaging()
797826
const lanePage = lanePaging?.pages[String(column.id)]
798827
const pagedCount = lanePage ? laneCount(lanePage, safeCards.length) : null
799-
const laneCountText = pagedCount
800-
? `${pagedCount.count}${pagedCount.atLeast ? '+' : ''}`
801-
: laneCountLabel(safeCards.length, countsAreWindowed)
828+
// objectstack#30431 — no count before the records settle; see `LaneCountPending`.
829+
const countPending = !useKanbanRecordsSettled()
830+
const laneCountText = countPending
831+
? <LaneCountPending />
832+
: pagedCount
833+
? `${pagedCount.count}${pagedCount.atLeast ? '+' : ''}`
834+
: laneCountLabel(safeCards.length, countsAreWindowed)
802835
// A lane total over a lane that holds more than it drew is a total of what
803836
// it drew, so it carries the `+` the windowed count carries.
804837
const laneTotalPartial = countsAreWindowed || lanePage?.hasMore === true
@@ -822,6 +855,7 @@ function KanbanColumnView({
822855
ref={setNodeRef}
823856
role="group"
824857
aria-label={column.title}
858+
aria-busy={countPending || undefined}
825859
aria-disabled={dropBlocked || undefined}
826860
data-kanban-drop-blocked={dropBlocked ? "" : undefined}
827861
title={dropBlocked ? dropBlockedReason : undefined}
@@ -869,7 +903,8 @@ function KanbanColumnView({
869903
Full
870904
</Badge>
871905
)}
872-
{!isCollapsed && <LaneTotal cards={safeCards} countsAreWindowed={laneTotalPartial} />}
906+
{/* A total over cards that have not arrived is the same false `0`. */}
907+
{!isCollapsed && !countPending && <LaneTotal cards={safeCards} countsAreWindowed={laneTotalPartial} />}
873908
</div>
874909
</div>
875910
</div>
@@ -988,9 +1023,12 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
9881023
* default). It gates the two places below that say "No cards", because both
9891024
* of them are assertions ABOUT THE DATA and this component is reachable —
9901025
* through `KanbanRenderer`'s `React.lazy` boundary — before the data exists.
991-
* ⛔ It gates nothing else: lanes, headers, counts and drop targets all keep
992-
* rendering while the rows are in flight. The full argument, the measured
993-
* reproduction and the settle contract live on the context.
1026+
* It gates the lane COUNTS for the same reason (objectstack#30431): every
1027+
* header that writes a count writes `LaneCountPending` instead until the
1028+
* records settle, since the number it holds before then is a `0` nobody
1029+
* answered. ⛔ It gates nothing else: lanes, headers, cards and drop targets
1030+
* all keep rendering while the rows are in flight. The full argument, the
1031+
* measured reproduction and the settle contract live on the context.
9941032
*/
9951033
const recordsSettled = useKanbanRecordsSettled()
9961034

@@ -1719,10 +1757,14 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
17191757
onToggle={() => toggleColumnCollapse(col.id, !collapsed)}
17201758
className=" text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase"
17211759
/>
1760+
{/* objectstack#30431 — the count, and the total, wait for the
1761+
records like the flat header's do (`LaneCountPending`). */}
17221762
{!collapsed && (
1723-
<span className="ml-2 text-xs text-muted-foreground">({laneCountLabel(col.cards.length, countsAreWindowed)})</span>
1763+
<span className="ml-2 text-xs text-muted-foreground">
1764+
{recordsSettled ? `(${laneCountLabel(col.cards.length, countsAreWindowed)})` : <LaneCountPending />}
1765+
</span>
17241766
)}
1725-
{!collapsed && (
1767+
{!collapsed && recordsSettled && (
17261768
<LaneTotal cards={col.cards} countsAreWindowed={countsAreWindowed} className="ml-2" />
17271769
)}
17281770
</div>
@@ -1748,7 +1790,9 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
17481790
>
17491791
<span className={cn("transition-transform text-xs", isCollapsed ? "" : "rotate-90")}>▶</span>
17501792
<span className=" text-xs font-semibold text-muted-foreground uppercase tracking-wider">{laneLabel}</span>
1751-
<span className=" text-xs text-muted-foreground">({laneCountLabel(laneCardCount, countsAreWindowed)})</span>
1793+
<span className=" text-xs text-muted-foreground">
1794+
{recordsSettled ? `(${laneCountLabel(laneCardCount, countsAreWindowed)})` : <LaneCountPending />}
1795+
</span>
17521796
</button>
17531797

17541798
{/* Lane content */}

‎ui/packages/plugin-kanban/src/KanbanRecordsSettled.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ import { createContext, useContext } from 'react';
3333
* ⇒ The only thing hiding the false announcement today is the lazy skeleton
3434
* happening to be slower than the fetch, and nothing makes it slower.
3535
*
36+
* The lane COUNTS are the same kind of claim and wait on the same signal
37+
* (objectstack#30431): before the records settle every header held `0`, which
38+
* a board opened right after publish showed under a list footer that already
39+
* read 「12 条记录」. See `LaneCountPending` in `KanbanImpl`.
40+
*
3641
* ## Why a tri-state settle and not a `loading` boolean
3742
*
3843
* `ObjectKanban` already holds a `loading` boolean — and wiring THAT into the

‎ui/packages/plugin-kanban/src/__tests__/ObjectKanban.lanePaging-12619.test.tsx‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,95 @@ describe('a card shows its owner\'s avatar when the host names the owner field (
291291
expect(document.querySelector('[data-kanban-card-owner]')).toBeNull();
292292
});
293293
});
294+
295+
/**
296+
* objectstack#30431 — a lane count is a claim about the data, so no header
297+
* states one before the records settle.
298+
*
299+
* Measured on cloud's two-plane stack (control plane + runtime): a board opened
300+
* right after publish, under a list view whose own count had already answered,
301+
* showed footer 「12 条记录」 over lanes reading 0 / 0 / 0 / 0 for as long as
302+
* its lane queries were in flight, then 2 / 4 / 2 / 3. Here every query the
303+
* board sends is held open until the test lets it go, which is the in-flight
304+
* window the stack showed.
305+
*/
306+
describe('a board states no lane count while its lane queries are in flight (objectstack#30431)', () => {
307+
/** The adapter above, with every `find` held until `release()`. */
308+
function heldAdapter(rows: Row[]) {
309+
const adapter = makeAdapter(rows);
310+
const answer = adapter.find;
311+
let release!: () => void;
312+
const gate = new Promise<void>((r) => {
313+
release = r;
314+
});
315+
adapter.find = vi.fn(async (object: string, params: any) => {
316+
await gate;
317+
return answer(object, params);
318+
});
319+
return { adapter, release: () => act(async () => release()) };
320+
}
321+
322+
const pendingMarker = (columnId: string) =>
323+
document.querySelector(`#kanban-col-${columnId}`)?.parentElement?.querySelector('[data-kanban-lane-count-pending]') ?? null;
324+
325+
it('a lane whose query has not answered shows no count, and its lane says it is busy', async () => {
326+
const { adapter, release } = heldAdapter([...deals('open', 2), ...deals('won', 4)]);
327+
mount(adapter);
328+
329+
// RIG SELF-CHECK — every lane's query has left and none has answered, and
330+
// the lanes are on screen: the board is not withheld, only the number.
331+
await waitFor(() => expect(queries(adapter)).toHaveLength(4));
332+
await waitFor(() => expect(lane('Open')).toBeInTheDocument());
333+
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
334+
335+
for (const [id, title] of [['open', 'Open'], ['won', 'Won'], ['lost', 'Lost']] as const) {
336+
// RED before the fix: '0' on every lane.
337+
expect(laneCountText(id)).toBe('');
338+
expect(pendingMarker(id)).not.toBeNull();
339+
expect(lane(title)).toHaveAttribute('aria-busy', 'true');
340+
}
341+
342+
await release();
343+
344+
await waitFor(() => expect(laneCards('Won')).toHaveLength(4));
345+
expect(laneCountText('open')).toBe('2');
346+
expect(laneCountText('won')).toBe('4');
347+
expect(pendingMarker('open')).toBeNull();
348+
expect(lane('Open')).not.toHaveAttribute('aria-busy');
349+
});
350+
351+
it('REVERSE: a lane that really holds nothing still reads 0 once its query answers', async () => {
352+
const { adapter, release } = heldAdapter([...deals('open', 2), ...deals('won', 4)]);
353+
mount(adapter);
354+
await waitFor(() => expect(queries(adapter)).toHaveLength(4));
355+
await release();
356+
357+
await waitFor(() => expect(laneCards('Open')).toHaveLength(2));
358+
// Withholding the count forever would also pass the test above.
359+
expect(laneCountText('lost')).toBe('0');
360+
expect(pendingMarker('lost')).toBeNull();
361+
expect(lane('Lost')).not.toHaveAttribute('aria-busy');
362+
});
363+
364+
it('the swimlane layout\'s column titles wait the same way', async () => {
365+
const { adapter, release } = heldAdapter([...deals('open', 2), ...deals('won', 1)]);
366+
mount(adapter, {}, { swimlaneField: 'region' });
367+
368+
await waitFor(() => expect(queries(adapter)).toHaveLength(1));
369+
const titleRow = await waitFor(() => {
370+
const row = document.querySelector('[aria-label="Kanban board with swimlanes"] [data-swimlane-scroll-row]');
371+
expect(row).not.toBeNull();
372+
return row as HTMLElement;
373+
});
374+
// RED before the fix: '(0)' under every column title.
375+
expect(titleRow.textContent).not.toMatch(/\(\d+\+?\)/);
376+
expect(titleRow.querySelectorAll('[data-kanban-lane-count-pending]')).toHaveLength(3);
377+
378+
await release();
379+
380+
await waitFor(() => expect(titleRow.textContent).toContain('(2)'));
381+
expect(titleRow.textContent).toContain('(1)');
382+
expect(titleRow.textContent).toContain('(0)');
383+
expect(titleRow.querySelectorAll('[data-kanban-lane-count-pending]')).toHaveLength(0);
384+
});
385+
});

0 commit comments

Comments
 (0)