feat(FN-3344): add navigation history hook unit and integration tests

Merged test(FN-3344): added comprehensive unit and integration tests for the navigation history hook, covering 470 lines of test coverage across navigation state management, history tracking, and edge cases.

Fusion-Task-Id: FN-3344
This commit is contained in:
Fusion
2026-05-03 20:37:12 -07:00
committed by gsxdsm
parent be7be0957d
commit 00a4e5a43a
4 changed files with 1138 additions and 58 deletions

View File

@@ -36,7 +36,7 @@ import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast"; import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm"; import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme"; import { useTheme } from "./hooks/useTheme";
import { useModalManager } from "./hooks/useModalManager"; import { useModalManager, type DetailTaskOrigin } from "./hooks/useModalManager";
import { useAppSettings } from "./hooks/useAppSettings"; import { useAppSettings } from "./hooks/useAppSettings";
import { useDeepLink } from "./hooks/useDeepLink"; import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites"; import { useFavorites } from "./hooks/useFavorites";
@@ -46,6 +46,7 @@ import { useMobileScrollLock } from "./hooks/useMobileScrollLock";
import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState"; import { useViewState, type TaskView } from "./hooks/useViewState";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews"; import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost"; import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { useProjectActions } from "./hooks/useProjectActions"; import { useProjectActions } from "./hooks/useProjectActions";
@@ -193,6 +194,15 @@ function AppInner() {
planningSessions: bgPlanningSessions, planningSessions: bgPlanningSessions,
}); });
// Viewport mode and mobile detection — MUST be before useViewState so that
// useNavigationHistory (and pushNav) are defined before handleTaskViewChange
// references them, avoiding a TDZ violation.
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Navigation history for mobile back button / iOS swipe-back.
const { pushNav, replaceCurrent } = useNavigationHistory({ enabled: isMobile });
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating // View state must be defined before useTasks since useTasks depends on taskView for SSE gating
const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({ const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({
projectsLoading, projectsLoading,
@@ -208,14 +218,20 @@ function AppInner() {
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id); const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
// History-aware view change handler — pushes nav entry on mobile.
const handleTaskViewChange = useCallback((newView: TaskView) => { const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") { if (newView === "missions") {
setMissionResumeSessionId(undefined); setMissionResumeSessionId(undefined);
setMissionTargetId(undefined); setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined); setMilestoneSliceResumeSessionId(undefined);
} }
const previousView = taskView;
handleChangeTaskView(newView); handleChangeTaskView(newView);
}, [handleChangeTaskView]); // pushNav reads enabledRef internally; isMobile not needed in deps.
if (previousView !== newView) {
pushNav({ type: "view", revert: () => handleChangeTaskView(previousView) });
}
}, [handleChangeTaskView, taskView, pushNav]);
// Tasks hook with project context and search query // Tasks hook with project context and search query
// SSE is only enabled for board/list views to free connection slots for mission detail fetches // SSE is only enabled for board/list views to free connection slots for mission detail fetches
@@ -283,8 +299,6 @@ function AppInner() {
}; };
}, [initialLoadComplete, projectsLoading, currentProjectLoading]); }, [initialLoadComplete, projectsLoading, currentProjectLoading]);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
// Keyboard visibility controls both MobileNavBar rendering and whether // Keyboard visibility controls both MobileNavBar rendering and whether
// the project content reserves bottom padding for the mobile nav bar. // the project content reserves bottom padding for the mobile nav bar.
@@ -395,6 +409,16 @@ function AppInner() {
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true; const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
const agentsEnabled = true; const agentsEnabled = true;
// Settings close handler with side effects — used by both AppModals
// onSettingsClose and the nav entry close callback so back-navigation
// also refreshes app settings and increments research-readiness.
// MUST be defined after useAppSettings so refreshAppSettings is not TDZ.
const handleSettingsClose = useCallback(() => {
modalManager.closeSettings();
setResearchReadinessVersion((current) => current + 1);
void refreshAppSettings();
}, [modalManager, refreshAppSettings]);
// Redirect to board if feature-gated views are disabled. // Redirect to board if feature-gated views are disabled.
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return; if (!settingsLoaded) return;
@@ -519,19 +543,21 @@ function AppInner() {
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes") => { const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes") => {
if (initialTab === "changes") { if (initialTab === "changes") {
modalManager.openDetailWithChangesTab(task); modalManager.openDetailWithChangesTab(task);
return; } else {
}
modalManager.openDetailTask(task, initialTab); modalManager.openDetailTask(task, initialTab);
}, [modalManager]); }
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, pushNav]);
const handleOpenTaskLogs = useCallback(async (taskId: string) => { const handleOpenTaskLogs = useCallback(async (taskId: string) => {
try { try {
const task = await fetchTaskDetail(taskId, currentProject?.id); const task = await fetchTaskDetail(taskId, currentProject?.id);
modalManager.openDetailTask(task, "logs"); modalManager.openDetailTask(task, "logs");
pushNav({ type: "modal", close: modalManager.closeDetailTask });
} catch (err) { } catch (err) {
addToast(`Failed to open task logs: ${(err as Error).message}`, "error"); addToast(`Failed to open task logs: ${(err as Error).message}`, "error");
} }
}, [modalManager, currentProject?.id, addToast]); }, [modalManager, currentProject?.id, addToast, pushNav]);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]); const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
@@ -565,6 +591,127 @@ function AppInner() {
setNodesOpen((prev) => !prev); setNodesOpen((prev) => !prev);
}, [nodesEnabled]); }, [nodesEnabled]);
// History-aware nodes toggle — pushes nav entry only when opening
const handleOpenNodesWithHistory = useCallback(() => {
if (!nodesEnabled) return;
if (!nodesOpen) {
setNodesOpen(true);
pushNav({ type: "view", revert: () => setNodesOpen(false) });
} else {
setNodesOpen(false);
}
}, [nodesEnabled, nodesOpen, pushNav]);
// History-aware modal open handlers — push nav entries on mobile only.
// Desktop (isMobile=false): pushNav/replaceCurrent are no-ops.
const openDetailTaskWithHistory = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, pushNav]);
const openSettingsWithHistory = useCallback((section?: Parameters<typeof modalManager.openSettings>[0]) => {
modalManager.openSettings(section);
pushNav({ type: "modal", close: handleSettingsClose });
}, [modalManager, pushNav, handleSettingsClose]);
const openNewTaskWithHistory = useCallback(() => {
modalManager.openNewTask();
pushNav({ type: "modal", close: modalManager.closeNewTask });
}, [modalManager, pushNav]);
const openPlanningWithHistory = useCallback(() => {
modalManager.openPlanning();
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
const openPlanningWithInitialPlanWithHistory = useCallback((initialPlan: string) => {
modalManager.openPlanningWithInitialPlan(initialPlan);
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
const resumePlanningWithHistory = useCallback(() => {
modalManager.resumePlanning();
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
const openSubtaskBreakdownWithHistory = useCallback((description: string) => {
modalManager.openSubtaskBreakdown(description);
pushNav({ type: "modal", close: modalManager.closeSubtask });
}, [modalManager, pushNav]);
const openGitHubImportWithHistory = useCallback(() => {
modalManager.openGitHubImport();
pushNav({ type: "modal", close: modalManager.closeGitHubImport });
}, [modalManager, pushNav]);
const toggleTerminalWithHistory = useCallback(() => {
// Only push if terminal is currently closed (opening)
if (!modalManager.terminalOpen) {
modalManager.toggleTerminal();
pushNav({ type: "modal", close: modalManager.closeTerminal });
} else {
modalManager.toggleTerminal();
}
}, [modalManager, pushNav]);
const openFilesWithHistory = useCallback(() => {
modalManager.openFiles();
pushNav({ type: "modal", close: modalManager.closeFiles });
}, [modalManager, pushNav]);
const openTodosWithHistory = useCallback(() => {
modalManager.openTodos();
pushNav({ type: "modal", close: modalManager.closeTodos });
}, [modalManager, pushNav]);
const openActivityLogWithHistory = useCallback(() => {
modalManager.openActivityLog();
pushNav({ type: "modal", close: modalManager.closeActivityLog });
}, [modalManager, pushNav]);
const openGitManagerWithHistory = useCallback(() => {
modalManager.openGitManager();
pushNav({ type: "modal", close: modalManager.closeGitManager });
}, [modalManager, pushNav]);
const openSystemStatsWithHistory = useCallback(() => {
modalManager.openSystemStats();
pushNav({ type: "modal", close: modalManager.closeSystemStats });
}, [modalManager, pushNav]);
const openSchedulesWithHistory = useCallback(() => {
modalManager.openSchedules();
pushNav({ type: "modal", close: modalManager.closeSchedules });
}, [modalManager, pushNav]);
const openScriptsWithHistory = useCallback(() => {
modalManager.openScripts();
pushNav({ type: "modal", close: modalManager.closeScripts });
}, [modalManager, pushNav]);
const openWorkflowStepsWithHistory = useCallback(() => {
modalManager.openWorkflowSteps();
pushNav({ type: "modal", close: modalManager.closeWorkflowSteps });
}, [modalManager, pushNav]);
const openUsageWithHistory = useCallback((anchorRect?: DOMRect | null) => {
modalManager.openUsage(anchorRect);
pushNav({ type: "modal", close: modalManager.closeUsage });
}, [modalManager, pushNav]);
// Modal-to-modal transition: scripts -> terminal uses replaceCurrent
const runScriptWithHistory = useCallback(async (name: string, command: string) => {
await modalManager.runScript(name, command);
replaceCurrent({ type: "modal", close: modalManager.closeTerminal });
}, [modalManager, replaceCurrent]);
// Modal-to-modal transition: settings -> onboarding uses replaceCurrent
const reopenOnboardingWithHistory = useCallback(() => {
modalManager.closeSettings();
modalManager.openModelOnboarding();
replaceCurrent({ type: "modal", close: modalManager.closeModelOnboarding });
}, [modalManager, replaceCurrent]);
const handleOpenProjectDirectory = useCallback(() => { const handleOpenProjectDirectory = useCallback(() => {
modalManager.setFileWorkspace("project"); modalManager.setFileWorkspace("project");
modalManager.openFiles(); modalManager.openFiles();
@@ -675,12 +822,12 @@ function AppInner() {
projectId: currentProject?.id, projectId: currentProject?.id,
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
workflowSteps, workflowSteps,
openTaskDetail: (task, initialTab) => modalManager.openDetailTask(task, initialTab), openTaskDetail: isMobile ? (task, initialTab) => openDetailTaskWithHistory(task, initialTab) : (task, initialTab) => modalManager.openDetailTask(task, initialTab),
renderTaskCard: (task) => ( renderTaskCard: (task) => (
<TaskCard <TaskCard
task={task} task={task}
projectId={currentProject?.id} projectId={currentProject?.id}
onOpenDetail={(value) => modalManager.openDetailTask(value)} onOpenDetail={isMobile ? (value: Task | TaskDetail) => openDetailTaskWithHistory(value) : (value: Task | TaskDetail) => modalManager.openDetailTask(value)}
addToast={addToast} addToast={addToast}
workflowStepNameLookup={workflowStepNameLookup} workflowStepNameLookup={workflowStepNameLookup}
/> />
@@ -759,7 +906,7 @@ function AppInner() {
projectId={currentProject?.id} projectId={currentProject?.id}
onSelectTask={(taskId) => { onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (task) modalManager.openDetailTask(task as TaskDetail); if (task) (isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask)(task as TaskDetail);
}} }}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
resumeSessionId={missionResumeSessionId} resumeSessionId={missionResumeSessionId}
@@ -793,7 +940,7 @@ function AppInner() {
<DocumentsView <DocumentsView
projectId={currentProject?.id} projectId={currentProject?.id}
addToast={addToast} addToast={addToast}
onOpenDetail={modalManager.openDetailTask} onOpenDetail={isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask}
/> />
</Suspense> </Suspense>
</PageErrorBoundary> </PageErrorBoundary>
@@ -871,12 +1018,12 @@ function AppInner() {
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}
onMoveTask={moveTask} onMoveTask={moveTask}
onPauseTask={pauseTask} onPauseTask={pauseTask}
onOpenDetail={modalManager.openDetailTask} onOpenDetail={isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask}
addToast={addToast} addToast={addToast}
onQuickCreate={handleBoardQuickCreate} onQuickCreate={handleBoardQuickCreate}
onNewTask={modalManager.openNewTask} onNewTask={isMobile ? openNewTaskWithHistory : modalManager.openNewTask}
onPlanningMode={modalManager.openPlanningWithInitialPlan} onPlanningMode={isMobile ? openPlanningWithInitialPlanWithHistory : modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown} onSubtaskBreakdown={isMobile ? openSubtaskBreakdownWithHistory : modalManager.openSubtaskBreakdown}
autoMerge={autoMerge} autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge} onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused} globalPaused={globalPaused}
@@ -914,13 +1061,13 @@ function AppInner() {
onMergeTask={mergeTask} onMergeTask={mergeTask}
onResetTask={resetTask} onResetTask={resetTask}
onDuplicateTask={duplicateTask} onDuplicateTask={duplicateTask}
onOpenDetail={(task, options) => modalManager.openDetailTask(task, undefined, options)} onOpenDetail={isMobile ? (task, options) => openDetailTaskWithHistory(task, undefined, options) : (task, options) => modalManager.openDetailTask(task, undefined, options)}
addToast={addToast} addToast={addToast}
globalPaused={globalPaused} globalPaused={globalPaused}
onNewTask={modalManager.openNewTask} onNewTask={isMobile ? openNewTaskWithHistory : modalManager.openNewTask}
onQuickCreate={handleBoardQuickCreate} onQuickCreate={handleBoardQuickCreate}
onPlanningMode={modalManager.openPlanningWithInitialPlan} onPlanningMode={isMobile ? openPlanningWithInitialPlanWithHistory : modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown} onSubtaskBreakdown={isMobile ? openSubtaskBreakdownWithHistory : modalManager.openSubtaskBreakdown}
availableModels={availableModels} availableModels={availableModels}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels} favoriteModels={favoriteModels}
@@ -955,27 +1102,27 @@ function AppInner() {
<> <>
<Header <Header
isElectron={isElectron} isElectron={isElectron}
onOpenSettings={handleOpenSettings} onOpenSettings={isMobile ? openSettingsWithHistory : handleOpenSettings}
onOpenGitHubImport={modalManager.openGitHubImport} onOpenGitHubImport={isMobile ? openGitHubImportWithHistory : modalManager.openGitHubImport}
onOpenPlanning={modalManager.openPlanning} onOpenPlanning={isMobile ? openPlanningWithHistory : modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning} onResumePlanning={isMobile ? resumePlanningWithHistory : modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length} activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={modalManager.openUsage} onOpenUsage={isMobile ? openUsageWithHistory : modalManager.openUsage}
onOpenActivityLog={modalManager.openActivityLog} onOpenActivityLog={isMobile ? openActivityLogWithHistory : modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats} onOpenSystemStats={isMobile ? openSystemStatsWithHistory : modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount} mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={modalManager.openSchedules} onOpenSchedules={isMobile ? openSchedulesWithHistory : modalManager.openSchedules}
onOpenGitManager={modalManager.openGitManager} onOpenGitManager={isMobile ? openGitManagerWithHistory : modalManager.openGitManager}
onOpenNodes={handleOpenNodes} onOpenNodes={isMobile ? handleOpenNodesWithHistory : handleOpenNodes}
showNodesButton={nodesEnabled} showNodesButton={nodesEnabled}
onOpenWorkflowSteps={modalManager.openWorkflowSteps} onOpenWorkflowSteps={isMobile ? openWorkflowStepsWithHistory : modalManager.openWorkflowSteps}
onOpenScripts={modalManager.openScripts} onOpenScripts={isMobile ? openScriptsWithHistory : modalManager.openScripts}
onRunScript={modalManager.runScript} onRunScript={isMobile ? runScriptWithHistory : modalManager.runScript}
onToggleTerminal={modalManager.toggleTerminal} onToggleTerminal={isMobile ? toggleTerminalWithHistory : modalManager.toggleTerminal}
onOpenFiles={modalManager.openFiles} onOpenFiles={isMobile ? openFilesWithHistory : modalManager.openFiles}
filesOpen={modalManager.filesOpen} filesOpen={modalManager.filesOpen}
onOpenTodos={modalManager.openTodos} onOpenTodos={isMobile ? openTodosWithHistory : modalManager.openTodos}
todosOpen={modalManager.todosOpen} todosOpen={modalManager.todosOpen}
todosEnabled={todosEnabled} todosEnabled={todosEnabled}
globalPaused={globalPaused} globalPaused={globalPaused}
@@ -1077,27 +1224,27 @@ function AppInner() {
footerVisible={viewMode === "project" && !!currentProject} footerVisible={viewMode === "project" && !!currentProject}
modalOpen={modalManager.anyModalOpen} modalOpen={modalManager.anyModalOpen}
keyboardOpen={mobileKeyboardOpen} keyboardOpen={mobileKeyboardOpen}
onOpenSettings={handleOpenSettings} onOpenSettings={isMobile ? openSettingsWithHistory : handleOpenSettings}
onOpenActivityLog={modalManager.openActivityLog} onOpenActivityLog={isMobile ? openActivityLogWithHistory : modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats} onOpenSystemStats={isMobile ? openSystemStatsWithHistory : modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodes} onOpenNodes={isMobile ? handleOpenNodesWithHistory : handleOpenNodes}
mailboxUnreadCount={mailboxUnreadCount} mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={modalManager.openGitManager} onOpenGitManager={isMobile ? openGitManagerWithHistory : modalManager.openGitManager}
onOpenWorkflowSteps={modalManager.openWorkflowSteps} onOpenWorkflowSteps={isMobile ? openWorkflowStepsWithHistory : modalManager.openWorkflowSteps}
onOpenSchedules={modalManager.openSchedules} onOpenSchedules={isMobile ? openSchedulesWithHistory : modalManager.openSchedules}
onOpenScripts={modalManager.openScripts} onOpenScripts={isMobile ? openScriptsWithHistory : modalManager.openScripts}
onToggleTerminal={modalManager.toggleTerminal} onToggleTerminal={isMobile ? toggleTerminalWithHistory : modalManager.toggleTerminal}
onOpenFiles={modalManager.openFiles} onOpenFiles={isMobile ? openFilesWithHistory : modalManager.openFiles}
onOpenTodos={modalManager.openTodos} onOpenTodos={isMobile ? openTodosWithHistory : modalManager.openTodos}
todosOpen={modalManager.todosOpen} todosOpen={modalManager.todosOpen}
onOpenGitHubImport={modalManager.openGitHubImport} onOpenGitHubImport={isMobile ? openGitHubImportWithHistory : modalManager.openGitHubImport}
onOpenPlanning={modalManager.openPlanning} onOpenPlanning={isMobile ? openPlanningWithHistory : modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning} onResumePlanning={isMobile ? resumePlanningWithHistory : modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length} activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={() => modalManager.openUsage(null)} onOpenUsage={isMobile ? () => openUsageWithHistory(null) : () => modalManager.openUsage(null)}
onViewAllProjects={handleViewAllProjects} onViewAllProjects={handleViewAllProjects}
onRunScript={modalManager.runScript} onRunScript={isMobile ? runScriptWithHistory : modalManager.runScript}
projectId={currentProject?.id} projectId={currentProject?.id}
showSkillsTab={skillsEnabled} showSkillsTab={skillsEnabled}
experimentalFeatures={{ experimentalFeatures={{
@@ -1145,12 +1292,8 @@ function AppInner() {
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask }} taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask }}
deepLink={{ handleDetailClose }} deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }} settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }}
onSettingsClose={() => { onSettingsClose={handleSettingsClose}
modalManager.closeSettings(); onReopenOnboarding={isMobile ? reopenOnboardingWithHistory : () => {
setResearchReadinessVersion((current) => current + 1);
void refreshAppSettings();
}}
onReopenOnboarding={() => {
modalManager.closeSettings(); modalManager.closeSettings();
modalManager.openModelOnboarding(); modalManager.openModelOnboarding();
}} }}

View File

@@ -0,0 +1,470 @@
/**
* Integration tests for mobile back-navigation (Android back button / iOS swipe-back).
*
* Verifies that the useNavigationHistory hook integration in App.tsx correctly:
* - Does NOT push history entries when opening modals on desktop
* - Does NOT dismiss modals on popstate on desktop
* - Pushes history entries when opening modals on mobile
* - Dismisses modals on popstate on mobile
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import type { Settings } from "@fusion/core";
import type { ProjectInfo } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
// ── API mocks ──────────────────────────────────────────────────────────────
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true },
};
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: any[]) => mockSubscribeSse(...args),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchTasks: vi.fn(() => Promise.resolve([])),
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })),
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchGlobalSettings: vi.fn(() => Promise.resolve({})),
fetchAuthStatus: vi.fn(() =>
Promise.resolve({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: true },
],
}),
),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
fetchExecutorStats: vi.fn(() => Promise.resolve({
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
lastActivityAt: new Date().toISOString(),
})),
fetchScripts: vi.fn(() => Promise.resolve({})),
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-1", command: "echo" })),
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
});
});
// ── Hook mocks ─────────────────────────────────────────────────────────────
const mockCreateTask = vi.fn();
const mockUseTasks = vi.fn(() => ({
tasks: [],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
vi.mock("../../hooks/useTasks", () => ({
useTasks: (_options?: any) => mockUseTasks(),
}));
vi.mock("../../hooks/useInsights", () => ({
useInsights: () => ({
sections: [], loading: false, error: null, latestRun: null,
isRunInFlight: false, runError: null, refresh: vi.fn(),
runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(),
dismissStates: new Map(), createTaskStates: new Map(),
totalCount: 0, dismissedCount: 0,
}),
}));
vi.mock("../../hooks/useRemoteNodeData", () => ({
useRemoteNodeData: vi.fn(() => ({
projects: [], tasks: [], health: null, loading: false,
error: null, refresh: vi.fn(),
})),
}));
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
useRemoteNodeEvents: vi.fn(() => ({ isConnected: false, lastEvent: null })),
}));
vi.mock("../../hooks/useBackgroundSessions", () => ({
useBackgroundSessions: vi.fn(() => ({
sessions: [], generating: false, needsInput: false,
planningSessions: [], dismissSession: vi.fn(),
})),
}));
const mockNodeContextValue = {
currentNode: null, currentNodeId: null, isRemote: false,
setCurrentNode: vi.fn(), clearCurrentNode: vi.fn(),
};
vi.mock("../../context/NodeContext", () => ({
NodeProvider: ({ children }: { children: React.ReactNode }) => children,
useNodeContext: vi.fn(() => mockNodeContextValue),
}));
vi.mock("../../components/model-onboarding-state", () => ({
isOnboardingResumable: () => false,
getOnboardingResumeStep: () => null,
getOnboardingState: () => null,
saveOnboardingState: vi.fn(),
clearOnboardingState: vi.fn(),
isOnboardingCompleted: () => false,
markOnboardingCompleted: vi.fn(),
markStepSkipped: vi.fn(),
getOnboardingCompletedAt: () => null,
getSkippedSteps: () => [],
getStepData: () => null,
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
}));
vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailModal: ({ task, onClose }: { task: { id: string; title?: string }; onClose: () => void }) => (
<div className="modal-overlay open" data-testid="task-detail-modal">
<div role="dialog" aria-label={task.title ?? task.id}>
<button type="button" className="modal-close" onClick={onClose}>Close</button>
<h2>{task.title ?? task.id}</h2>
</div>
</div>
),
}));
vi.mock("../../components/SettingsModal", () => ({
SettingsModal: ({ onClose }: { onClose: () => void }) => (
<div className="modal-overlay open" data-testid="settings-modal">
<h2>Settings</h2>
<button type="button" data-testid="settings-close-btn" onClick={onClose}>Close</button>
</div>
),
}));
vi.mock("../../components/GitHubImportModal", () => ({
GitHubImportModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div className="modal-overlay open" data-testid="github-import-modal"><h2>Import from GitHub</h2></div> : null,
}));
vi.mock("../../components/PlanningModeModal", () => ({
PlanningModeModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div className="modal-overlay open" data-testid="planning-modal"><h2>Planning Mode</h2></div> : null,
}));
vi.mock("../../components/AgentsView", () => ({
AgentsView: () => <div data-testid="agents-view">Agents view</div>,
}));
vi.mock("../../components/ResearchView", () => ({
ResearchView: () => <div data-testid="research-view">Research</div>,
}));
vi.mock("../../components/TodoView", () => ({
TodoView: () => <div data-testid="todo-view">Todo</div>,
}));
vi.mock("../../components/QuickChatFAB", () => ({
QuickChatFAB: () => null,
}));
vi.mock("../../components/ScriptsModal", () => ({
ScriptsModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div className="modal-overlay open" data-testid="scripts-modal"><h2>Scripts</h2></div> : null,
}));
vi.mock("../../components/TerminalModal", () => ({
TerminalModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div className="modal-overlay open" data-testid="terminal-modal"><h2>Terminal</h2></div> : null,
}));
vi.mock("../../components/SetupWizardModal", () => ({
SetupWizardModal: () => <div>Welcome to Fusion</div>,
}));
vi.mock("../../components/ModelOnboardingModal", () => ({
ModelOnboardingModal: ({ onComplete }: { onComplete: () => void }) => (
<div className="modal-overlay open">
<h2>Set Up AI</h2>
<button type="button" onClick={onComplete}>Skip for now</button>
</div>
),
}));
vi.mock("../../components/CustomModelDropdown", () => ({
CustomModelDropdown: () => <select data-testid="mock-model-dropdown"><option>Select</option></select>,
}));
// ── Project state mocks ────────────────────────────────────────────────────
const DEFAULT_PROJECT_ID = "proj_123";
const mockProjectsState = {
projects: [] as ProjectInfo[],
loading: false,
error: null as string | null,
};
const mockCurrentProjectState = {
currentProject: {
id: DEFAULT_PROJECT_ID,
name: "Test Project",
path: "/test",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "",
updatedAt: "",
},
setCurrentProject: vi.fn(),
clearCurrentProject: vi.fn(),
loading: false,
};
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
projects: mockProjectsState.projects,
loading: mockProjectsState.loading,
error: mockProjectsState.error,
refresh: vi.fn(async () => {}),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
}),
}));
vi.mock("../../hooks/useCurrentProject", () => ({
useCurrentProject: () => mockCurrentProjectState,
}));
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({
connectionStatus: "connected",
sendInput: vi.fn(),
resize: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: vi.fn(),
onSessionInvalid: vi.fn(() => vi.fn()),
}),
}));
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: [], loading: false, error: null,
refresh: vi.fn(), register: vi.fn(), update: vi.fn(),
unregister: vi.fn(), healthCheck: vi.fn(),
})),
}));
// ── Viewport / keyboard mocks ──────────────────────────────────────────────
const mockUseViewportMode = vi.fn(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
getViewportMode: () => "desktop",
}));
const mockUseMobileKeyboard = vi.fn(() => ({
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (..._args: unknown[]) => mockUseMobileKeyboard(..._args),
}));
// ── Import App AFTER all mocks ─────────────────────────────────────────────
import { App } from "../../App";
function dispatchPopState(state: Record<string, unknown> | null) {
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
describe("Navigation history integration", () => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
beforeEach(() => {
vi.clearAllMocks();
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());
mockCreateTask.mockReset();
mockUseTasks.mockReset();
mockUseTasks.mockImplementation(() => ({
tasks: [],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
mockProjectsState.projects = [];
mockProjectsState.loading = false;
mockProjectsState.error = null;
mockCurrentProjectState.currentProject = {
id: DEFAULT_PROJECT_ID,
name: "Test Project",
path: "/test",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
mockCurrentProjectState.setCurrentProject.mockClear();
mockCurrentProjectState.clearCurrentProject.mockClear();
mockNodeContextValue.currentNode = null;
mockNodeContextValue.currentNodeId = null;
mockNodeContextValue.isRemote = false;
mockNodeContextValue.setCurrentNode.mockClear();
mockNodeContextValue.clearCurrentNode.mockClear();
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
});
localStorage.removeItem("fusion-dashboard-current-node");
localStorage.removeItem("kb-onboarding-state");
localStorage.removeItem("kb-dashboard-view-mode");
window.history.pushState = vi.fn();
window.history.replaceState = vi.fn();
});
afterEach(() => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
});
async function renderAppAndWait() {
const result = render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Settings")).toBeTruthy();
});
return result;
}
// 1. Desktop: opening Settings does NOT push history entry
it("does not push history entry when opening Settings modal on desktop", async () => {
await renderAppAndWait();
const settingsBtn = screen.getByTitle("Settings");
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByTestId("settings-modal")).toBeTruthy();
});
// On desktop, pushState should NOT be called for modal opens
expect(window.history.pushState).not.toHaveBeenCalled();
});
// 2. Desktop: popstate does NOT dismiss modals
it("does not dismiss Settings modal on popstate in desktop mode", async () => {
await renderAppAndWait();
const settingsBtn = screen.getByTitle("Settings");
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByTestId("settings-modal")).toBeTruthy();
});
// Simulate back button
dispatchPopState({ navIndex: 0 });
// Settings modal should still be open
expect(screen.getByTestId("settings-modal")).toBeTruthy();
});
// 3. Desktop: view changes do NOT push history entries
it("does not push history entry for view changes on desktop", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID);
localStorage.setItem(taskViewStorageKey, "board");
await renderAppAndWait();
// Switch to agents view
const agentsTab = screen.queryByTitle("Agents");
if (!agentsTab) return;
fireEvent.click(agentsTab);
await waitFor(() => {
expect(screen.getByTestId("agents-view")).toBeTruthy();
});
// pushState should NOT have been called for view changes on desktop
expect(window.history.pushState).not.toHaveBeenCalled();
});
// 4. Desktop: popstate does NOT revert view changes
it("does not revert view change on popstate in desktop mode", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID);
localStorage.setItem(taskViewStorageKey, "board");
await renderAppAndWait();
const agentsTab = screen.queryByTitle("Agents");
if (!agentsTab) return;
fireEvent.click(agentsTab);
await waitFor(() => {
expect(screen.getByTestId("agents-view")).toBeTruthy();
});
// Simulate back button
dispatchPopState({ navIndex: 0 });
// Agents view should still be showing (desktop ignores popstate for navigation)
expect(screen.getByTestId("agents-view")).toBeTruthy();
});
// 5. Verify useNavigationHistory is called with enabled=true on mobile
it("calls useNavigationHistory with enabled=true on mobile", async () => {
mockUseViewportMode.mockReturnValue("mobile");
// On mobile, the Settings button is not in the header — it's in the
// MobileNavBar "More" menu. We just verify the mock was called.
render(<App />);
await waitFor(() => {
// Wait for the app to render something
expect(document.querySelector('.project-content')).toBeTruthy();
});
// The useViewportMode hook should have been called and returned "mobile"
expect(mockUseViewportMode).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useNavigationHistory } from "../useNavigationHistory";
describe("useNavigationHistory", () => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
let pushStateSpy: ReturnType<typeof vi.fn>;
let replaceStateSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pushStateSpy = vi.fn();
replaceStateSpy = vi.fn();
// Use real replaceState for setup so history.state is actually set,
// then install spies for assertions.
window.history.replaceState = originalReplaceState;
window.history.replaceState({}, "");
window.history.pushState = pushStateSpy;
window.history.replaceState = replaceStateSpy;
});
afterEach(() => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
});
function renderHookWithHistory(enabled = true) {
return renderHook(({ enabled: e }) => useNavigationHistory({ enabled: e }), {
initialProps: { enabled },
});
}
function dispatchPopState(state: Record<string, unknown> | null) {
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
// 1. pushNav calls history.pushState with incremented navIndex
it("pushNav calls history.pushState with incremented navIndex", () => {
const { result } = renderHookWithHistory();
const close = vi.fn();
const revert = vi.fn();
act(() => {
result.current.pushNav({ type: "modal", close });
});
expect(pushStateSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ navIndex: 1 }),
"",
);
act(() => {
result.current.pushNav({ type: "view", revert });
});
expect(pushStateSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ navIndex: 2 }),
"",
);
});
// 2. popstate invokes close callback
it("popstate invokes close callback and pops entry from stack", () => {
const close = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close });
});
dispatchPopState({ navIndex: 0 });
expect(close).toHaveBeenCalledTimes(1);
});
// 3. popstate sets isPopping flag — pushNav is a no-op during pop handling
it("pushNav is a no-op during pop handling (isPopping guard)", () => {
let pushNavFromCallback: ((entry: { type: "modal"; close: () => void }) => void) | null = null;
const closeThatCapturesPushNav = vi.fn(() => {
// This simulates a state change inside the pop callback.
// We don't call pushNav here because the callback doesn't have access to it.
// Instead, we verify the isPopping mechanism by checking no extra pushState calls.
});
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: closeThatCapturesPushNav });
});
const pushCallCount = pushStateSpy.mock.calls.length;
dispatchPopState({ navIndex: 0 });
expect(closeThatCapturesPushNav).toHaveBeenCalledTimes(1);
// The pushState call count should not have increased from pop handling
expect(pushStateSpy.mock.calls.length).toBe(pushCallCount);
});
// 4. Multiple pushes create a stack — pop back pops correct entries
it("multiple pushes create a stack and pop back pops correct entries", () => {
const close1 = vi.fn();
const close2 = vi.fn();
const close3 = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: close1 });
result.current.pushNav({ type: "modal", close: close2 });
result.current.pushNav({ type: "modal", close: close3 });
});
// Pop back to index 2 (pops entry 3 only)
dispatchPopState({ navIndex: 2 });
expect(close3).toHaveBeenCalledTimes(1);
expect(close2).not.toHaveBeenCalled();
expect(close1).not.toHaveBeenCalled();
});
// 5. replaceCurrent updates the top entry
it("replaceCurrent updates the top entry", () => {
const closeA = vi.fn();
const closeB = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: closeA });
});
act(() => {
result.current.replaceCurrent({ type: "modal", close: closeB });
});
// Pop back to index 0 — should call closeB (the replacement), not closeA
dispatchPopState({ navIndex: 0 });
expect(closeB).toHaveBeenCalledTimes(1);
expect(closeA).not.toHaveBeenCalled();
});
// 6. replaceCurrent calls history.replaceState (not pushState)
it("replaceCurrent calls history.replaceState", () => {
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: vi.fn() });
});
const pushCountBefore = pushStateSpy.mock.calls.length;
const replaceCountBefore = replaceStateSpy.mock.calls.length;
act(() => {
result.current.replaceCurrent({ type: "modal", close: vi.fn() });
});
// pushState should not have been called again
expect(pushStateSpy.mock.calls.length).toBe(pushCountBefore);
// replaceState should have been called
expect(replaceStateSpy.mock.calls.length).toBeGreaterThan(replaceCountBefore);
});
// 7. No-op when enabled: false (desktop mode)
it("pushNav and replaceCurrent are no-ops when enabled is false", () => {
const { result } = renderHookWithHistory(false);
act(() => {
result.current.pushNav({ type: "modal", close: vi.fn() });
});
expect(pushStateSpy).not.toHaveBeenCalled();
act(() => {
result.current.replaceCurrent({ type: "modal", close: vi.fn() });
});
expect(replaceStateSpy).not.toHaveBeenCalled();
});
// 8. No popstate handling when enabled: false
it("no callbacks are invoked on popstate when enabled is false", () => {
const close = vi.fn();
const { result } = renderHookWithHistory(false);
// Even if we somehow had entries, popstate shouldn't trigger callbacks
dispatchPopState({ navIndex: 0 });
expect(close).not.toHaveBeenCalled();
});
// 9. Duplicate-consecutive-push guard
it("skips duplicate consecutive pushes with the same callback", () => {
const close = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close });
});
expect(pushStateSpy).toHaveBeenCalledTimes(1);
// Push the same entry again — should be skipped
act(() => {
result.current.pushNav({ type: "modal", close });
});
expect(pushStateSpy).toHaveBeenCalledTimes(1); // still only 1 call
});
// 10. Handles rapid popstate (iOS fast swipe) — pops multiple entries
it("handles rapid popstate by popping all entries back to target index", () => {
const close1 = vi.fn();
const close2 = vi.fn();
const close3 = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: close1 });
result.current.pushNav({ type: "modal", close: close2 });
result.current.pushNav({ type: "modal", close: close3 });
});
// iOS fast swipe pops all the way back to index 0
dispatchPopState({ navIndex: 0 });
// All 3 close callbacks should be called in reverse order
expect(close3).toHaveBeenCalledTimes(1);
expect(close2).toHaveBeenCalledTimes(1);
expect(close1).toHaveBeenCalledTimes(1);
// Verify reverse order: close3 called before close2, close2 before close1
const callOrder = [
close3.mock.invocationCallOrder[0],
close2.mock.invocationCallOrder[0],
close1.mock.invocationCallOrder[0],
];
expect(callOrder[0]).toBeLessThan(callOrder[1]);
expect(callOrder[1]).toBeLessThan(callOrder[2]);
});
// Additional: enabled can be toggled dynamically
it("respects dynamic enabled changes", () => {
const close = vi.fn();
const { result, rerender } = renderHookWithHistory();
// Start enabled
act(() => {
result.current.pushNav({ type: "modal", close });
});
expect(pushStateSpy).toHaveBeenCalledTimes(1);
// Disable
rerender({ enabled: false });
act(() => {
result.current.pushNav({ type: "modal", close: vi.fn() });
});
// Should still be 1 — no-op when disabled
expect(pushStateSpy).toHaveBeenCalledTimes(1);
});
// Additional: replaceCurrent is a no-op when stack is empty
it("replaceCurrent is a no-op when stack is empty", () => {
const { result } = renderHookWithHistory();
// Clear the spy to remove any setup calls
replaceStateSpy.mockClear();
act(() => {
result.current.replaceCurrent({ type: "modal", close: vi.fn() });
});
expect(replaceStateSpy).not.toHaveBeenCalled();
});
// Additional: preserves existing history.state properties
it("preserves existing history.state properties when pushing", () => {
// Use the real replaceState to set actual state, then re-install spy
window.history.replaceState = originalReplaceState;
window.history.replaceState({ existingKey: "existingValue" }, "");
window.history.replaceState = replaceStateSpy;
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "modal", close: vi.fn() });
});
expect(pushStateSpy).toHaveBeenCalledWith(
expect.objectContaining({
navIndex: 1,
existingKey: "existingValue",
}),
"",
);
});
// Additional: view entries call revert on popstate
it("popstate invokes revert callback for view entries", () => {
const revert = vi.fn();
const { result } = renderHookWithHistory();
act(() => {
result.current.pushNav({ type: "view", revert });
});
dispatchPopState({ navIndex: 0 });
expect(revert).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef } from "react";
/**
* A navigation entry on the back-navigation stack.
*
* - `modal` entries wrap a close callback for dismissing a modal.
* - `view` entries wrap a revert callback for restoring a previous view.
*
* All callbacks MUST be idempotent — safe to call multiple times even if the
* underlying state has already changed (e.g., calling `setDetailTask(null)`
* when `detailTask` is already `null`). This handles edge cases where an
* auto-close side effect fires before a `popstate` event.
*/
export type NavEntry =
| { type: "modal"; close: () => void }
| { type: "view"; revert: () => void };
export interface UseNavigationHistoryOptions {
/** Only active on mobile. When false, pushNav/replaceCurrent are no-ops. */
enabled: boolean;
}
export interface UseNavigationHistoryResult {
/** Push a navigation entry onto the stack and call history.pushState. */
pushNav: (entry: NavEntry) => void;
/** Replace the top-of-stack entry and call history.replaceState. */
replaceCurrent: (entry: NavEntry) => void;
}
/**
* Centralized back-navigation hook that integrates the browser History API
* (`pushState`/`popstate`) with modal and view state machines.
*
* On mobile, every modal open and view change pushes a history entry so that
* the Android hardware back button and iOS swipe-back gesture dismiss the
* top modal or revert to the previous view.
*
* On desktop (`enabled: false`), all operations are no-ops and no `popstate`
* listener is registered, leaving desktop behavior completely unchanged.
*/
export function useNavigationHistory(
options: UseNavigationHistoryOptions,
): UseNavigationHistoryResult {
const { enabled } = options;
// Internal navigation stack. Each entry corresponds to one history entry.
const stackRef = useRef<NavEntry[]>([]);
// Guard flag: when popstate fires and calls a close/revert callback, the
// resulting state change (e.g. `setDetailTask(null)`) must NOT re-push a
// history entry. pushNav checks this flag and skips if true.
const isPoppingRef = useRef(false);
// Keep enabled in a ref so the popstate handler can read the current value
// without needing to be re-registered on every change.
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const pushNav = useCallback(
(entry: NavEntry) => {
if (!enabledRef.current) return;
// Prevent re-push during pop handling
if (isPoppingRef.current) return;
// Guard against duplicate consecutive pushes (rapid taps)
const top = stackRef.current[stackRef.current.length - 1];
if (top) {
const topCallback = top.type === "modal" ? top.close : top.revert;
const newCallback = entry.type === "modal" ? entry.close : entry.revert;
if (topCallback === newCallback) return;
}
stackRef.current.push(entry);
// Preserve existing history.state properties (e.g. from useDeepLink)
// while adding our navIndex.
const navIndex = stackRef.current.length;
const existingState =
typeof window !== "undefined" && window.history.state
? window.history.state
: {};
window.history.pushState({ ...existingState, navIndex }, "");
},
[], // stable — reads from refs
);
const replaceCurrent = useCallback(
(entry: NavEntry) => {
if (!enabledRef.current) return;
if (stackRef.current.length === 0) return;
stackRef.current[stackRef.current.length - 1] = entry;
// Preserve existing history.state properties while updating navIndex
const navIndex = stackRef.current.length;
const existingState =
typeof window !== "undefined" && window.history.state
? window.history.state
: {};
window.history.replaceState({ ...existingState, navIndex }, "");
},
[], // stable — reads from refs
);
// Register popstate listener. Always registers in browser environments but
// the handler checks enabledRef.current to skip when disabled (desktop).
useEffect(() => {
if (typeof window === "undefined") return;
const handlePopState = (event: PopStateEvent) => {
if (!enabledRef.current) return;
const targetIndex = event.state?.navIndex ?? 0;
const currentLength = stackRef.current.length;
if (targetIndex >= currentLength) return;
// Calculate how many entries were popped
const poppedCount = currentLength - targetIndex;
if (poppedCount <= 0) return;
isPoppingRef.current = true;
try {
// Pop entries in reverse order (top of stack first)
for (let i = 0; i < poppedCount; i++) {
const entry = stackRef.current.pop();
if (entry) {
if (entry.type === "modal") {
entry.close();
} else {
entry.revert();
}
}
}
} finally {
isPoppingRef.current = false;
}
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
return { pushNav, replaceCurrent };
}