fix(dashboard): dropping into a renamed intake lane reset progress without asking (#3011)
The **sixth** instance of the async-memo shape #2998 documents — and the only one that **loses work** rather than mis-rendering. `handleDrop` gates the "Preserve Progress?" confirmation on the lane's role: ```js const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column); if (shouldPrompt) { const keepProgress = await confirm({ … }); … } ``` …but its `useCallback` deps were `[addToast, allTasks, column, confirm, onMoveTask, tasks, t]` — no `columnFlags`. The board resolves workflow traits after first paint, so the DOM keeps the closure built during the pre-load render, where `columnFlags` is `undefined` and the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`. A renamed intake lane is not a member. **Result: a card with completed steps dropped into that lane moves with `shouldPrompt === false`.** The user is never offered "Keep Progress", and the steps are reset silently. ## How this was found It is the last unverified candidate from the derivation-aware scan I posted on #2998, where I explicitly declined to file it as a bug without checking. Checking it is what turned it from a scanner hit into this. ## Severity, stated honestly `allTasks` and `tasks` are in the dep list and change identity on any task-list refresh, so the stale closure is rebuilt within seconds on a busy board. The exposure is the quiet gap right after the traits land — **bounded**, like the near-duplicate chip (#2997), not permanent like the ticker (#2996) whose only refreshing dependency fired at local midnight. Bounded still matters here because the cost is not a wrong pixel: it is completed steps discarded without a prompt, and the window is exactly when someone has just opened a board and starts dragging. ## Verification | state | result | |---|---| | clean | 2/2 pass | | revert `columnFlags` from the deps | **1 failed / 1 passed** | The paired negative asserts a non-pre-implementation lane still moves **without** prompting — a fix that prompts everywhere turns the dialog into noise that gets clicked through, costing the same progress it protects. The observable is `confirm`, not `onMoveTask`: whether the user was *asked* is the contract, and asserting on the move alone passes either way. `tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC gate exit 0, 4/4 across both `Column` flags-arrival suites. ## Running tally of this shape ticker (#2996) · near-duplicate chip (#2997) · fan-out trait index (#2993) · merge signature (#3001) · lifecycle dates (#3007) · this. Six, in two components plus the fan-out path. #3001 called the merge signature "the last live site"; it was the last of *that* sweep's nine candidates, and two more have surfaced since from a different scan. Worth knowing before anyone declares the class closed again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/column-drop-prompt-renamed-lanes.md
Normal file
7
.changeset/column-drop-prompt-renamed-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Dropping a card with completed steps into a renamed intake lane now asks before resetting progress.
|
||||||
|
category: fix
|
||||||
|
dev: `handleDrop` in Column omitted `columnFlags` from its `useCallback` deps, so the pre-load closure saw the legacy lane ids and skipped the confirmation.
|
||||||
@@ -536,7 +536,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]);
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-31-00:40:
|
||||||
|
`columnFlags` BELONGS IN THIS LIST — the drop handler asks it whether this lane is pre-implementation.
|
||||||
|
|
||||||
|
`shouldPrompt` gates the "Preserve Progress?" confirmation on
|
||||||
|
`isPreImplementationColumnRole(columnFlags, column)`. The flags arrive after first paint, and
|
||||||
|
`useCallback` without them in its deps hands the DOM the closure built during the pre-load render.
|
||||||
|
In that closure the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`, which does not
|
||||||
|
contain a renamed intake/hold lane — so `shouldPrompt` is false and a card with completed steps is
|
||||||
|
moved WITHOUT asking, silently resetting progress the user was meant to be offered a choice about.
|
||||||
|
|
||||||
|
SEVERITY, STATED HONESTLY: `allTasks` and `tasks` are in this list and change identity on any
|
||||||
|
task-list refresh, so the stale closure is rebuilt within seconds on an active board — a window,
|
||||||
|
not a permanent wrong answer, like the near-duplicate chip and unlike the TaskCard ticker whose
|
||||||
|
refreshing dependency fired only at local midnight. The window is exactly the quiet gap after the
|
||||||
|
traits land, and a drop inside it loses work without a prompt.
|
||||||
|
*/
|
||||||
|
}, [addToast, allTasks, column, columnFlags, confirm, onMoveTask, tasks, t]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:BoardPromote 2026-07-25-04:55:
|
FNXC:BoardPromote 2026-07-25-04:55:
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-31-00:45:
|
||||||
|
DROPPING ONTO A RENAMED INTAKE LANE RESET PROGRESS WITHOUT ASKING.
|
||||||
|
|
||||||
|
`handleDrop` gates the "Preserve Progress?" confirmation on
|
||||||
|
`isPreImplementationColumnRole(columnFlags, column)`, but its `useCallback` deps omitted `columnFlags`.
|
||||||
|
The board resolves workflow traits after first paint, so the DOM kept the closure built during the
|
||||||
|
pre-load render — one holding `columnFlags === undefined`, where the helper falls back to
|
||||||
|
`LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` and a renamed intake lane is not a member.
|
||||||
|
|
||||||
|
Consequence: a card with completed steps dropped into that lane moved with `shouldPrompt === false`,
|
||||||
|
so the user was never offered "Keep Progress" and the steps were reset silently. This is the only one
|
||||||
|
of the six instances of this shape that LOSES WORK rather than mis-renders.
|
||||||
|
|
||||||
|
SEVERITY: `allTasks`/`tasks` are also in the dep list and change on any task-list refresh, so the
|
||||||
|
stale closure is rebuilt within seconds on a busy board. The exposure is the quiet gap right after
|
||||||
|
the traits land — bounded, like the near-duplicate chip, not permanent like the ticker.
|
||||||
|
|
||||||
|
THE OBSERVABLE IS `confirm`, not the move: whether the prompt was offered is the contract. Asserting
|
||||||
|
on `onMoveTask` alone would pass whether or not the user was asked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import type { Task, Column as ColumnType } from "@fusion/core";
|
||||||
|
import { Column } from "../Column";
|
||||||
|
|
||||||
|
const confirmMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: confirmMock }) }));
|
||||||
|
vi.mock("../TaskCard", () => ({ TaskCard: () => <article /> }));
|
||||||
|
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div /> }));
|
||||||
|
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div /> }));
|
||||||
|
|
||||||
|
const BASE = { description: "t", createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z" };
|
||||||
|
|
||||||
|
/* A card carrying real step progress — the only kind the prompt is meant to protect. */
|
||||||
|
const worked = {
|
||||||
|
id: "KB-WORK", title: "has progress", column: "building",
|
||||||
|
steps: [{ id: "s1", name: "step", status: "done" }],
|
||||||
|
...BASE,
|
||||||
|
} as unknown as Task;
|
||||||
|
|
||||||
|
/* Stable identity across both renders: if this changed, the callback would be rebuilt for an
|
||||||
|
unrelated reason and the test would pass without the fix. */
|
||||||
|
const allTasks = [worked];
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
column: "drafting" as ColumnType,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
showWorktreeGrouping: false,
|
||||||
|
onMoveTask: vi.fn().mockResolvedValue({} as Task),
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
addToast: vi.fn(),
|
||||||
|
tasks: [],
|
||||||
|
allTasks,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `drafting` is this board's intake lane — it just isn't called `todo`. */
|
||||||
|
const DRAFTING_IS_INTAKE = { intake: true, hold: true } as const;
|
||||||
|
|
||||||
|
function dropOnto(container: HTMLElement) {
|
||||||
|
const zone = container.querySelector(".column") ?? container.firstElementChild!;
|
||||||
|
fireEvent.drop(zone, { dataTransfer: { getData: () => "KB-WORK" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the drop-progress prompt when column traits arrive after first paint", () => {
|
||||||
|
it("prompts once the renamed intake lane's flags have arrived", async () => {
|
||||||
|
confirmMock.mockReset();
|
||||||
|
confirmMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { container, rerender } = render(<Column {...(props as never)} />);
|
||||||
|
rerender(<Column {...(props as never)} columnFlags={DRAFTING_IS_INTAKE as never} />);
|
||||||
|
|
||||||
|
dropOnto(container);
|
||||||
|
|
||||||
|
/* Without `columnFlags` in the deps the DOM keeps the pre-load closure, `shouldPrompt` is false,
|
||||||
|
and the card moves with its progress reset and no question asked. */
|
||||||
|
await waitFor(() => { expect(confirmMock).toHaveBeenCalledTimes(1); });
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The paired negative: the fix must not turn every lane into a prompting one. A drop onto a lane that
|
||||||
|
is NOT pre-implementation must still move silently, or the prompt becomes noise and gets clicked
|
||||||
|
through — which costs the same progress it was added to protect.
|
||||||
|
*/
|
||||||
|
it("does not prompt for a lane that is not pre-implementation", async () => {
|
||||||
|
confirmMock.mockReset();
|
||||||
|
confirmMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { container, rerender } = render(<Column {...(props as never)} />);
|
||||||
|
rerender(<Column {...(props as never)} columnFlags={{ countsTowardWip: true } as never} />);
|
||||||
|
|
||||||
|
dropOnto(container);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(confirmMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user