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;
|
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);
|
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