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:
gsxdsm
2026-07-31 00:02:50 -07:00
committed by GitHub
parent e78bf20d55
commit cf6062c524
3 changed files with 131 additions and 1 deletions

View 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.

View File

@@ -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(

View File

@@ -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();
});
});