fix(dashboard): key subtask breakdown by project to stop cross-project draft leaks
Same bug class as the Planning project-switch fix: on a project swap the SubtaskBreakdownModal survived as the same instance, so the isOpen=false reset ran with the NEW projectId and persisted the old project's draft description under the new project's storage key (and kept it in memory), making the breakdown reopen in project B with project A's draft. - AppModals keys SubtaskBreakdownModal by project id so a swap remounts it. - Unmount cleanup now saves the in-progress draft under the instance's own project key (constant per keyed instance), so the old project's draft is preserved instead of dropped or misfiled. - Dismissal on swap was already covered by closeProjectScopedModals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Switching projects now dismisses the previous project's modals and resets the Planning view to the new project.
|
||||
summary: Switching projects now dismisses the old project's modals and resets Planning and subtask breakdown to the new project.
|
||||
category: fix
|
||||
dev: New `closeProjectScopedModals()` on the modal manager, invoked by project select/view-all/setup-complete; embedded PlanningModeModal is keyed by project id so a running plan's stream, session list, and persisted active session no longer leak across projects.
|
||||
dev: New `closeProjectScopedModals()` on the modal manager, invoked by project select/view-all/setup-complete; embedded PlanningModeModal and SubtaskBreakdownModal are keyed by project id so running streams, session lists, and per-project persisted drafts/active sessions no longer leak across projects (subtask drafts save on unmount under their own project key).
|
||||
|
||||
@@ -402,7 +402,18 @@ export function AppModals({
|
||||
/>
|
||||
|
||||
<ModalErrorBoundary>
|
||||
{/*
|
||||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||||
Key the subtask breakdown by project so a project swap remounts it, mirroring the
|
||||
embedded Planning view. Without the remount, the swap flipped isOpen=false and the
|
||||
NEW projectId in the same render, so resetState persisted the old project's draft
|
||||
description under the new project's storage key and kept it in memory — reopening
|
||||
the breakdown in the new project showed the previous project's draft. The old
|
||||
instance's unmount cleanup closes its stream and saves the draft under its own
|
||||
project key.
|
||||
*/}
|
||||
<SubtaskBreakdownModal
|
||||
key={projectId ?? "no-project"}
|
||||
isOpen={modalManager.isSubtaskOpen}
|
||||
onClose={closeSubtaskWithNav}
|
||||
initialDescription={modalManager.subtaskInitialDescription ?? ""}
|
||||
|
||||
@@ -359,10 +359,24 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
})();
|
||||
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
/*
|
||||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||||
This modal only unmounts on a project switch (AppModals keys it by project) or app
|
||||
teardown — a normal close just renders null and resetState persists the draft. Save the
|
||||
draft on unmount too, under THIS instance's projectId (constant for its lifetime thanks
|
||||
to the key), so switching projects mid-draft neither drops the old project's draft nor
|
||||
writes it under the new project's storage key.
|
||||
*/
|
||||
const localDescriptionRef = useRef(localDescription);
|
||||
localDescriptionRef.current = localDescription;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.close();
|
||||
if (localDescriptionRef.current) {
|
||||
saveSubtaskDescription(localDescriptionRef.current, projectId);
|
||||
}
|
||||
};
|
||||
// projectId is intentionally omitted: it is constant per keyed instance.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect as reactUseEffect } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
|
||||
@@ -35,8 +36,20 @@ vi.mock("../PlanningModeModal", () => ({
|
||||
PlanningModeModal: () => null,
|
||||
}));
|
||||
|
||||
/*
|
||||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||||
The subtask breakdown mock records mounts so the project-keyed remount contract is
|
||||
testable: a project swap must create a fresh instance, not update the old one (which
|
||||
persisted the previous project's draft under the new project's storage key).
|
||||
*/
|
||||
const subtaskMounts = vi.hoisted(() => [] as Array<string | undefined>);
|
||||
vi.mock("../SubtaskBreakdownModal", () => ({
|
||||
SubtaskBreakdownModal: () => null,
|
||||
SubtaskBreakdownModal: ({ projectId }: { projectId?: string }) => {
|
||||
reactUseEffect(() => {
|
||||
subtaskMounts.push(projectId);
|
||||
}, []);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../TerminalModal", () => ({
|
||||
@@ -218,6 +231,7 @@ describe("AppModals", () => {
|
||||
closeSetupWizard: vi.fn(),
|
||||
openModelOnboarding: vi.fn(),
|
||||
closeModelOnboarding: vi.fn(),
|
||||
closeProjectScopedModals: vi.fn(),
|
||||
onPlanningTaskCreated: vi.fn(),
|
||||
onPlanningTasksCreated: vi.fn(),
|
||||
onSubtaskTasksCreated: vi.fn(),
|
||||
@@ -262,6 +276,39 @@ describe("AppModals", () => {
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||||
Switching projects must remount the subtask breakdown (project-keyed), mirroring the
|
||||
embedded Planning view: a prop update on the surviving instance ran resetState with the
|
||||
NEW projectId and persisted the old project's draft under the new project's storage key.
|
||||
*/
|
||||
it("remounts the subtask breakdown when the active project changes", () => {
|
||||
const buildProps = (projectId: string) => ({
|
||||
projectId,
|
||||
tasks: [],
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
addToast: vi.fn(),
|
||||
toasts: mockToasts,
|
||||
removeToast: vi.fn(),
|
||||
modalManager: mockModalManager,
|
||||
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
||||
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
||||
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
||||
deepLink: { handleDetailClose: vi.fn() },
|
||||
settings: mockSettings,
|
||||
});
|
||||
|
||||
subtaskMounts.length = 0;
|
||||
const { rerender } = render(<AppModals {...buildProps("proj_a")} />);
|
||||
expect(subtaskMounts).toEqual(["proj_a"]);
|
||||
|
||||
rerender(<AppModals {...buildProps("proj_b")} />);
|
||||
|
||||
// A fresh mount for the new project — not a prop update on the old instance.
|
||||
expect(subtaskMounts).toEqual(["proj_a", "proj_b"]);
|
||||
});
|
||||
|
||||
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {
|
||||
const manager = {
|
||||
...mockModalManager,
|
||||
|
||||
@@ -877,4 +877,30 @@ describe("SubtaskBreakdownModal", () => {
|
||||
expect(await screen.findByText("Stream error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||||
AppModals keys this modal by project, so a project swap unmounts the old instance.
|
||||
Unmount must persist the in-progress draft under the OLD project's storage key — the
|
||||
pre-fix prop-update path ran resetState with the NEW projectId and wrote project A's
|
||||
draft into project B's persisted description.
|
||||
*/
|
||||
describe("project switch unmount", () => {
|
||||
it("saves the draft under its own project id on unmount", async () => {
|
||||
const { saveSubtaskDescription } = await import("../../hooks/modalPersistence");
|
||||
const { unmount } = render(
|
||||
<SubtaskBreakdownModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
initialDescription="Draft from project A"
|
||||
onTasksCreated={onTasksCreated}
|
||||
projectId="proj_a"
|
||||
/>,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(saveSubtaskDescription).toHaveBeenCalledWith("Draft from project A", "proj_a");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user