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:
@@ -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);
|
pushNav({ type: "modal", close: modalManager.closeDetailTask });
|
||||||
}, [modalManager]);
|
}, [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();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
150
packages/dashboard/app/hooks/useNavigationHistory.ts
Normal file
150
packages/dashboard/app/hooks/useNavigationHistory.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user