fix(dashboard): the live elapsed-time indicator never started on a renamed board (#2996)
## How this was found By generalizing the memo-dependency defect in the blocker fan-out (#2993) into a sweep for memoized hooks that read a lane value absent from their dependency list — rather than treating that one as a one-off. **13 raw hits, 12 benign** (refs, or values reached through a covered object). This is the one that's a live defect. ## The defect `wantsLiveTimeIndicator` decides whether a card subscribes to the shared time ticker. It reads `isWipColumn`, `isReviewColumn` and `taskColumnFlags` — all derived from the `taskColumnFlags` **prop** — while its dependency array listed only `task.*` fields. Those flags arrive **after first paint**: the board resolves workflow traits asynchronously. So: 1. first computation runs with flags `undefined`; 2. role helpers fall back to legacy ids — `isWipColumnRole(undefined, "building")` is **false**; 3. the card declines the ticker; 4. flags arrive, but `task.column` hasn't changed, so nothing in the dep array changed; 5. the memo never recomputes. **No live elapsed time, for the life of the mount.** ## Why it survived On a legacy board the fallback already answers `true` on the very first paint (`column === "in-progress"`), so the memo's initial value is correct and the stale list costs nothing. The defect is **renamed-board-only**. This repo also has no `react-hooks/exhaustive-deps` rule, so the entire class is invisible to lint — and a disable directive for that rule fails CI, so these lists are maintained by hand. ## Measured The test was written **first** and was red for the right reason before any fix — the control and the negative passed, and only the renamed case failed: | stage | result | |---|---| | before the fix | `expected false to be true` (renamed case only) | | after | 3 passed | | `TaskCard.test` + `cli-states` + `oversight` + new suite | **456 tests green** | | gates | census + FNXC green; lint and `tsc` clean | The assertion is on `useLiveTimeTicker(enabled)` — `enabled` *is* `wantsLiveTimeIndicator`, so it observes the subscription itself rather than a proxy for it. ## The negative case is the one that matters Recomputing must not degrade into "every card subscribes". A card in the renamed **complete** lane must stay off the shared ticker, or the fix trades one stalled indicator for sixty cards waking a backgrounded tab — the exact cost the shared-ticker refactor documented at this site (it replaced 60 per-card `setInterval`s precisely because mobile browsers discard a page that never goes idle). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/taskcard-live-ticker-renamed-lanes.md
Normal file
7
.changeset/taskcard-live-ticker-renamed-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Cards in renamed in-progress or review lanes now show their live elapsed-time indicator.
|
||||
category: fix
|
||||
dev: `wantsLiveTimeIndicator`'s dependency list omitted `isWipColumn`/`isReviewColumn`/`taskColumnFlags`, so the memo kept the pre-load answer computed before workflow traits resolved.
|
||||
@@ -1732,7 +1732,24 @@ function TaskCardComponent({
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt]);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-23:40:
|
||||
THE LANE ROLES BELONG IN THIS LIST, or the card never subscribes on a renamed board.
|
||||
|
||||
`isWipColumn`/`isReviewColumn` derive from the `taskColumnFlags` PROP, which arrives after first
|
||||
paint — the board resolves workflow traits asynchronously. The first computation therefore runs
|
||||
with the flags undefined, the role helpers fall back to the legacy ids, and on a renamed board that
|
||||
answers false. When the flags arrive `task.column` is unchanged, so a list of `task.*` fields alone
|
||||
never recomputes and the pre-load answer sticks: no live elapsed-time indicator, for the life of
|
||||
the mount.
|
||||
|
||||
A legacy board hid this completely, because there the fallback already answers true on the first
|
||||
paint and the stale list costs nothing.
|
||||
|
||||
This repo has no `react-hooks/exhaustive-deps` rule, so the list is maintained by hand and a
|
||||
disable directive for that rule fails CI.
|
||||
*/
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt, isWipColumn, isReviewColumn, taskColumnFlags]);
|
||||
|
||||
const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-23:35:
|
||||
THE LIVE ELAPSED-TIME INDICATOR NEVER STARTED FOR CARDS IN A RENAMED WIP OR REVIEW LANE.
|
||||
|
||||
`wantsLiveTimeIndicator` decides whether a card subscribes to the shared ticker. It reads
|
||||
`isWipColumn` / `isReviewColumn` / `taskColumnFlags` — all derived from the `taskColumnFlags` PROP —
|
||||
while its dependency array lists only `task.*` fields.
|
||||
|
||||
`taskColumnFlags` arrives ASYNCHRONOUSLY: the board resolves workflow traits after first paint, so
|
||||
the first computation always runs with the flags undefined. The role helpers then fall back to the
|
||||
legacy ids, and on a renamed board `isWipColumnRole(undefined, "building")` is false. The card
|
||||
decides it needs no ticker. When the flags arrive, `task.column` has not changed — so nothing in the
|
||||
dependency array changed, the memo never recomputes, and the card never subscribes.
|
||||
|
||||
WHY THE DEFAULT BOARD HID IT. With legacy ids the fallback answers `true` on the very first paint
|
||||
(`column === "in-progress"`), so the memo's initial value is already correct and the stale dependency
|
||||
list costs nothing. The defect is renamed-board-only, which is why it survived.
|
||||
|
||||
Found by generalizing the memo-dependency defect in the blocker fan-out (#2993) into a sweep for
|
||||
memoized hooks that read a lane value absent from their deps, rather than treating that one as a
|
||||
one-off. This repo has no `react-hooks/exhaustive-deps` rule, so the whole class is invisible to lint.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
|
||||
/* The subscription flag is the observable: `enabled` is exactly `wantsLiveTimeIndicator`. */
|
||||
const tickerCalls: boolean[] = [];
|
||||
vi.mock("../../hooks/useLiveTimeTicker", () => ({
|
||||
useLiveTimeTicker: (enabled: boolean) => {
|
||||
tickerCalls.push(enabled);
|
||||
return Date.now();
|
||||
},
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function taskIn(column: string): Task {
|
||||
return {
|
||||
id: "KB-1",
|
||||
title: "a card",
|
||||
description: "t",
|
||||
column,
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
executionStartedAt: "2026-06-01T00:00:00.000Z",
|
||||
cumulativeActiveMs: 60_000,
|
||||
steps: [],
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
/** Did the card subscribe on its LATEST render? */
|
||||
const subscribedNow = () => tickerCalls[tickerCalls.length - 1] === true;
|
||||
|
||||
describe("the live time indicator when column traits arrive after first paint", () => {
|
||||
/* Control: on a legacy board the fallback answers correctly on the first paint, so this passes
|
||||
with or without the fix and proves the harness observes a real subscription. */
|
||||
it("default vocabulary: a card in `in-progress` subscribes with no flags at all", () => {
|
||||
tickerCalls.length = 0;
|
||||
render(<TaskCard task={taskIn("in-progress")} onOpenDetail={noop} addToast={noop} />);
|
||||
|
||||
expect(subscribedNow()).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
The defect. First paint has no flags, so the renamed lane reads as not-WIP and the card declines
|
||||
the ticker; the flags then arrive and nothing recomputes.
|
||||
*/
|
||||
it("renamed vocabulary: a card subscribes once its WIP traits arrive", () => {
|
||||
tickerCalls.length = 0;
|
||||
const { rerender } = render(
|
||||
<TaskCard task={taskIn("building")} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
/* Pre-load: correctly declines, because nothing yet says this lane is WIP. */
|
||||
expect(subscribedNow()).toBe(false);
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={taskIn("building")}
|
||||
taskColumnFlags={{ countsTowardWip: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(subscribedNow()).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
The paired negative: recomputing must not degrade into "every card subscribes". A card whose
|
||||
resolved traits say terminal is finished work and must stay off the shared ticker, or the fix
|
||||
trades one stalled indicator for sixty cards waking a backgrounded tab — the exact cost the
|
||||
shared-ticker refactor documented at this site.
|
||||
*/
|
||||
it("renamed vocabulary: a card in the renamed COMPLETE lane does not subscribe", () => {
|
||||
tickerCalls.length = 0;
|
||||
const { rerender } = render(
|
||||
<TaskCard task={taskIn("shipped")} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={taskIn("shipped")}
|
||||
taskColumnFlags={{ complete: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(subscribedNow()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user