FN-6998: fix mobile mission detail back navigation

Ensure mobile Missions exits detail tabs back to the mission list before outer navigation.

- Track mobile detail visibility instead of selected mission IDs when adding navigation entries.
- Avoid duplicate history entries for Structure/Activity tab switches and mission refreshes.
- Cover browser back and visible Back button behavior across mobile tabs while desktop stays unchanged.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6998-mobile-mission-back.md          |   7 ++
 .../dashboard/app/components/MissionManager.tsx    |  35 +++++--
 .../__tests__/MissionManager.swipe-back.test.tsx   | 114 ++++++++++++++++++---
 3 files changed, 131 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-6998

Fusion-Task-Lineage: d3dbf3b7-0195-498f-a57f-ed83c6a422e7
This commit is contained in:
gsxdsm
2026-06-25 01:31:50 -07:00
parent d08e8db2c3
commit 2d2dd50f32
3 changed files with 132 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mobile Missions back navigation from mission detail tabs.
category: fix
dev: Tracks mission detail visibility for mobile history entries instead of selected mission IDs.

View File

@@ -2476,22 +2476,37 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
t,
);
const previousSelectedMissionIdRef = useRef<string | null>(selectedMission?.id ?? null);
const previousMobileDetailVisibleRef = useRef(false);
useEffect(() => {
const previousSelectedMissionId = previousSelectedMissionIdRef.current;
const currentSelectedMissionId = selectedMission?.id ?? null;
previousSelectedMissionIdRef.current = currentSelectedMissionId;
if (!isActive) {
previousMobileDetailVisibleRef.current = false;
}
}, [isActive]);
if (!isActive || !isMobile || !currentSelectedMissionId || previousSelectedMissionId === currentSelectedMissionId) {
useEffect(() => {
const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission);
if (!isMobile) {
// Keep the mobile detail flag untouched on desktop so split-pane refreshes do not consume or create mobile nav entries.
return;
}
// MissionManager may already sit behind an App-level modal nav entry.
// On mobile, selecting a mission stacks a view entry on top so back goes
// detail → list → modal close instead of skipping the in-modal list.
pushNav({ type: "view", revert: handleBackToList });
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]);
if (!isMobileDetailVisible) {
previousMobileDetailVisibleRef.current = false;
return;
}
/*
FNXC:MissionNavigation 2026-06-24-23:48:
Mobile Missions treats Structure and Activity as tabs inside one detail view. Push one view entry when detail becomes visible so browser/Android Back exits to the Missions list before any outer modal or app navigation, and do not push again for tab switches, SSE refreshes, or cached mission rehydration.
*/
if (!previousMobileDetailVisibleRef.current) {
pushNav({ type: "view", revert: handleBackToList });
}
previousMobileDetailVisibleRef.current = true;
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission]);
const selectedMilestoneTelemetry = useMemo(() => {
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {

View File

@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import { type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionManager } from "../MissionManager";
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
@@ -8,6 +8,7 @@ const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchMissionEvents = vi.fn();
const mockFetchAssertions = vi.fn();
const mockFetchMilestoneValidation = vi.fn();
const mockFetchMilestoneValidationTelemetry = vi.fn();
@@ -42,6 +43,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
@@ -107,6 +109,18 @@ const rollup = {
state: "not_started" as const,
};
const missionEvents = [
{
id: "ME-001",
missionId: "M-001",
eventType: "mission_updated",
description: "Activity tab loaded",
metadata: null,
timestamp: "2026-01-03T00:00:00.000Z",
seq: 1,
},
];
function HistoryHarness({ children }: { children: ReactNode }) {
const history = useNavigationHistory({ enabled: true });
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
@@ -121,6 +135,7 @@ describe("MissionManager mobile swipe-back", () => {
mockFetchMissions.mockResolvedValue(missions);
mockFetchMission.mockResolvedValue(missionDetail);
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchMissionEvents.mockResolvedValue({ events: missionEvents, total: missionEvents.length });
mockFetchAssertions.mockResolvedValue([]);
mockFetchMilestoneValidation.mockResolvedValue(rollup);
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
@@ -135,11 +150,7 @@ describe("MissionManager mobile swipe-back", () => {
});
it("pushes a mobile nav entry when opening mission detail and popstate returns to the list", async () => {
render(
<HistoryHarness>
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
</HistoryHarness>,
);
renderMissionManager();
await userSelectMission();
await waitFor(() => {
@@ -151,28 +162,77 @@ describe("MissionManager mobile swipe-back", () => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
});
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
await expectMissionListVisible();
});
it("does not push a nav entry on desktop mission selection", async () => {
it("returns from the Activity tab to the mobile mission list on browser back", async () => {
renderMissionManager();
await userSelectMission();
await openActivityTab();
expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument();
expect(screen.getByText("Activity tab loaded")).toBeInTheDocument();
expect(window.history.pushState).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await expectMissionListVisible();
});
it("returns from the Activity tab with the visible mobile Back button", async () => {
renderMissionManager();
await userSelectMission();
await openActivityTab();
fireEvent.click(screen.getByTestId("mission-back-btn"));
await expectMissionListVisible();
});
it("does not add duplicate mobile nav entries for repeated tab changes", async () => {
renderMissionManager();
await userSelectMission();
await openActivityTab();
fireEvent.click(screen.getByTestId("mission-tab-structure"));
fireEvent.click(screen.getByTestId("mission-tab-activity"));
fireEvent.click(screen.getByTestId("mission-tab-activity"));
expect(window.history.pushState).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
});
await expectMissionListVisible();
});
it("does not push a nav entry on desktop mission selection or tab switching", async () => {
mockViewportMode.mockReturnValue("desktop");
render(
<HistoryHarness>
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
</HistoryHarness>,
);
renderMissionManager();
await waitFor(() => {
expect(screen.getByText("Database Schema")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument();
});
expect(window.history.pushState).not.toHaveBeenCalled();
});
});
function renderMissionManager() {
render(
<HistoryHarness>
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
</HistoryHarness>,
);
}
async function userSelectMission() {
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
@@ -182,3 +242,27 @@ async function userSelectMission() {
expect(mockFetchMission).toHaveBeenCalledWith("M-001", undefined);
});
}
async function openActivityTab() {
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(mockFetchMissionEvents).toHaveBeenCalledWith(
"M-001",
expect.objectContaining({ limit: 50, offset: 0 }),
undefined,
);
});
}
async function expectMissionListVisible() {
await waitFor(() => {
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
expect(screen.queryByTestId("mission-activity-tab")).not.toBeInTheDocument();
expect(screen.queryByText("Activity tab loaded")).not.toBeInTheDocument();
});
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
expect(screen.getByText("API Redesign")).toBeInTheDocument();
}