fix(dashboard): finished cards on a renamed board never refreshed their diff stats (#3001)
Last live site from the stale-lane-dependency sweep recorded in #2998. **Nine persistent candidates; this and #2996 were real.** The other seven are covered transitively or by a dependency that already carries the flags — each checked by hand rather than filed, which is the whole point of that doc. ## The defect `mergeSignature` is the key `useTaskDiffStats` uses to notice that a merge changed what a finished card should display. It early-returns `undefined` unless `isCompleteColumn`, which derives from the `taskColumnFlags` **prop** — and its dependency list was three `task.*` fields, none of which is that prop or carries it. The flags arrive after first paint, so: 1. first computation runs with flags `undefined`; 2. the role helper falls back to the legacy id — `isCompleteColumnRole(undefined, "shipped")` is **false**; 3. the key is `undefined`; 4. for a card **already merged when the board loaded** — the common case for anything sitting in a completion lane — neither `mergeDetails` field changes afterwards either; 5. nothing recomputes, and the hook never learns a merge landed. A legacy board hides it: `column === "done"` answers true on the very first paint. ## Measured | check | result | |---|---| | test written first | red for the right reason — control and negative passed, only the arrival case failed (`expected undefined to be defined`) | | after the fix | 3 passed | | dropping the dependency again | that same case fails | | `TaskCard.test` + new suite | **391 tests green** | | gates | census + FNXC green; lint and `tsc` clean | The observable is the options object handed to `useTaskDiffStats`, so the assertion is on the value this component is responsible for producing rather than on what the hook does with it. ## The negative case Recomputing must not hand a signature to cards that aren't finished. An in-flight card has no merge to key on, and inventing one would have the diff-stats hook treat unfinished work as landed. ## The sweep is now closed For anyone picking this up later: the four "bounded" sites from #2998's triage remain unexamined **by design** — their dependency lists all contain a fast-refreshing value (`allTasks`, a live clock), so any wrong answer there survives only until the next update. That's a judgement about priority, not a claim that they're correct. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/taskcard-merge-signature-renamed-lanes.md
Normal file
7
.changeset/taskcard-merge-signature-renamed-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Finished cards on renamed boards now refresh their diff stats after a merge.
|
||||||
|
category: fix
|
||||||
|
dev: `mergeSignature`'s dependency list omitted `isCompleteColumn`, so the key stayed undefined when column traits arrived after first paint.
|
||||||
@@ -1890,7 +1890,19 @@ function TaskCardComponent({
|
|||||||
const landedFilesCount = task.mergeDetails?.landedFiles?.length ?? "";
|
const landedFilesCount = task.mergeDetails?.landedFiles?.length ?? "";
|
||||||
const filesChanged = task.mergeDetails?.filesChanged ?? "";
|
const filesChanged = task.mergeDetails?.filesChanged ?? "";
|
||||||
return `${landedFilesCount}:${filesChanged}`;
|
return `${landedFilesCount}:${filesChanged}`;
|
||||||
}, [task.column, task.mergeDetails?.landedFiles?.length, task.mergeDetails?.filesChanged]);
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-23:25:
|
||||||
|
`isCompleteColumn` BELONGS IN THIS LIST — it derives from the async `taskColumnFlags` prop.
|
||||||
|
|
||||||
|
Without it the signature is computed once, during the pre-load render where the flags are undefined
|
||||||
|
and the role helper falls back to the legacy id. On a renamed board that answers false, so the key
|
||||||
|
is `undefined`; and for a card already merged when the board loaded, neither `mergeDetails` field
|
||||||
|
changes afterwards either, so nothing ever recomputes and `useTaskDiffStats` loses the signal that
|
||||||
|
a merge changed what the card should show.
|
||||||
|
|
||||||
|
A legacy board hid it: `column === "done"` is already true on the first paint.
|
||||||
|
*/
|
||||||
|
}, [task.column, task.mergeDetails?.landedFiles?.length, task.mergeDetails?.filesChanged, isCompleteColumn]);
|
||||||
|
|
||||||
// Viewport-gated diff stats fetching - only fetch when card is visible
|
// Viewport-gated diff stats fetching - only fetch when card is visible
|
||||||
const { stats: diffStats, loading: diffLoading } = useTaskDiffStats(
|
const { stats: diffStats, loading: diffLoading } = useTaskDiffStats(
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-23:20:
|
||||||
|
THE CARD'S DIFF-STATS REFRESH KEY STAYED UNDEFINED ON A RENAMED BOARD.
|
||||||
|
|
||||||
|
`mergeSignature` is the value `useTaskDiffStats` uses to notice that a merge changed what a finished
|
||||||
|
card should display. It early-returns `undefined` unless `isCompleteColumn`, which derives from the
|
||||||
|
`taskColumnFlags` PROP — and its dependency list was three `task.*` fields, none of which is that
|
||||||
|
prop or anything carrying it.
|
||||||
|
|
||||||
|
The flags arrive after first paint (the board resolves workflow traits asynchronously). The first
|
||||||
|
computation therefore runs with them undefined, the role helper falls back to the legacy id, and on a
|
||||||
|
renamed board `isCompleteColumnRole(undefined, "shipped")` is false — so the signature is `undefined`.
|
||||||
|
When the flags arrive, `task.column` has not changed and, for a card already merged when the board
|
||||||
|
loaded, neither have the two `mergeDetails` fields. Nothing recomputes and the key stays absent.
|
||||||
|
|
||||||
|
WHY THE DEFAULT BOARD HID IT: `column === "done"` answers true on the first paint, so the memo's
|
||||||
|
initial value is already right.
|
||||||
|
|
||||||
|
Last live site from the sweep recorded in the learnings doc (nine persistent candidates, seven
|
||||||
|
covered transitively or by a dependency that already carries the flags).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { TaskCard } from "../TaskCard";
|
||||||
|
|
||||||
|
/* Capture the options `useTaskDiffStats` is called with — `mergeSignature` is the observable. */
|
||||||
|
const seen: (string | undefined)[] = [];
|
||||||
|
vi.mock("../../hooks/useTaskDiffStats", () => ({
|
||||||
|
useTaskDiffStats: (
|
||||||
|
_id: string,
|
||||||
|
_column: string,
|
||||||
|
_sha: string | undefined,
|
||||||
|
_projectId: string | undefined,
|
||||||
|
options: { mergeSignature?: string },
|
||||||
|
) => {
|
||||||
|
seen.push(options?.mergeSignature);
|
||||||
|
return { stats: undefined, loading: false };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
/** A card that merged BEFORE the board loaded: its mergeDetails never change afterwards. */
|
||||||
|
function mergedTaskIn(column: string): Task {
|
||||||
|
return {
|
||||||
|
id: "KB-1",
|
||||||
|
title: "a merged card",
|
||||||
|
description: "t",
|
||||||
|
column,
|
||||||
|
createdAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
steps: [],
|
||||||
|
mergeDetails: { commitSha: "abc1234", filesChanged: 3, landedFiles: ["a.ts", "b.ts", "c.ts"] },
|
||||||
|
} as unknown as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = () => seen[seen.length - 1];
|
||||||
|
|
||||||
|
describe("the diff-stats refresh key when column traits arrive after first paint", () => {
|
||||||
|
/* Control: on a legacy board the key is present from the first paint, with no flags at all. */
|
||||||
|
it("default vocabulary: a card in `done` gets a merge signature", () => {
|
||||||
|
seen.length = 0;
|
||||||
|
render(<TaskCard task={mergedTaskIn("done")} onOpenDetail={noop} addToast={noop} />);
|
||||||
|
|
||||||
|
expect(latest()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The defect: the renamed complete lane never produced a key, so a merge could not refresh stats. */
|
||||||
|
it("renamed vocabulary: the key appears once the complete trait arrives", () => {
|
||||||
|
seen.length = 0;
|
||||||
|
const { rerender } = render(
|
||||||
|
<TaskCard task={mergedTaskIn("shipped")} onOpenDetail={noop} addToast={noop} />,
|
||||||
|
);
|
||||||
|
expect(latest()).toBeUndefined();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TaskCard
|
||||||
|
task={mergedTaskIn("shipped")}
|
||||||
|
taskColumnFlags={{ complete: true }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(latest()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The paired negative: recomputing must not hand a signature to cards that are not finished. An
|
||||||
|
in-flight card has no merge to key on, and inventing one would make the diff-stats hook treat
|
||||||
|
unfinished work as landed.
|
||||||
|
*/
|
||||||
|
it("renamed vocabulary: a card in the WIP lane still has no signature", () => {
|
||||||
|
seen.length = 0;
|
||||||
|
const { rerender } = render(
|
||||||
|
<TaskCard task={mergedTaskIn("building")} onOpenDetail={noop} addToast={noop} />,
|
||||||
|
);
|
||||||
|
rerender(
|
||||||
|
<TaskCard
|
||||||
|
task={mergedTaskIn("building")}
|
||||||
|
taskColumnFlags={{ countsTowardWip: true }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(latest()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user