FN-7257: dismiss task details on Android Back

Route Android Back through Fusion's mobile navigation stack before native fallback.

- Dispatch a cancelable native-back event from the Capacitor shell before browser-history or app-exit fallback.
- Handle the native-back event in dashboard navigation history so mobile task details dismiss like browser Back and swipe-back.
- Cover Android Back behavior across native shell and task-detail navigation tests.
- Add a patch changeset for the published CLI/mobile bundle.

Files changed:
 .changeset/fn-7257-android-back-task-detail.md     |   7 +
 .../__tests__/TaskDetail.swipe-back.test.tsx       | 214 +++++++++++++++++++++
 .../dashboard/app/hooks/useNavigationHistory.ts    |  15 ++
 packages/mobile/src/__tests__/native-shell.test.ts | 151 ++++++++++++++-
 packages/mobile/src/index.ts                       |   7 +-
 packages/mobile/src/plugins/native-shell.ts        | 103 +++++++++-
 6 files changed, 494 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7257

Fusion-Task-Lineage: 90d0bc32-8968-4876-a5b4-51b34888d480

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 20:50:14 -07:00
parent 480d4d03fd
commit 9fb706944e
6 changed files with 494 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Dismiss mobile task details when Android Back is pressed before falling back to app exit.
category: fix
dev: Routes Capacitor Android Back through the dashboard navigation-history stack via a cancelable native event.

View File

@@ -186,12 +186,19 @@ vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailContent: ({
task,
onBackToBoard,
onOpenDetail,
}: {
task: { id: string; title?: string };
onBackToBoard?: () => void;
onOpenDetail?: (task: { id: string; title: string }) => void;
}) => (
<div data-testid="task-detail-main-panel-content">
{onBackToBoard ? <button type="button" data-testid="task-detail-back-to-board" onClick={onBackToBoard}>Back to board</button> : null}
{task.id === "FN-1" && onOpenDetail ? (
<button type="button" data-testid="task-detail-open-nested" onClick={() => onOpenDetail({ id: "FN-2", title: "Nested Main Panel Task" })}>
Open nested
</button>
) : null}
<h2>{task.title ?? task.id}</h2>
</div>
),
@@ -307,6 +314,16 @@ function dispatchPopState(state: Record<string, unknown> | null) {
});
}
function dispatchNativeAndroidBack(): boolean {
let handled = false;
act(() => {
const event = new CustomEvent("fusion:native-back", { cancelable: true, detail: { source: "android-back" } });
window.dispatchEvent(event);
handled = event.defaultPrevented;
});
return handled;
}
async function renderAppAndWait(expectedTestId: string = "board-view") {
const result = render(<App />);
await waitFor(() => {
@@ -318,6 +335,7 @@ async function renderAppAndWait(expectedTestId: string = "board-view") {
describe("Task detail mobile swipe-back", () => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
const originalBack = window.history.back;
beforeEach(() => {
vi.clearAllMocks();
@@ -345,11 +363,87 @@ describe("Task detail mobile swipe-back", () => {
localStorage.clear();
window.history.pushState = vi.fn();
window.history.replaceState = vi.fn();
window.history.back = vi.fn();
});
afterEach(() => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
window.history.back = originalBack;
});
it("dismisses the board main-panel task detail on native Android Back", async () => {
const task = makeTask("FN-1", "Board Detail");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
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(),
refreshTasks: vi.fn(),
}));
await renderAppAndWait("board-view");
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(true);
expect(window.history.back).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument();
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view")).toBeInTheDocument();
});
});
it("restores the previous board main-panel detail on native Android Back", async () => {
const task = makeTask("FN-1", "Parent Main Panel Task");
mockUseTasks.mockImplementation(() => ({
tasks: [task, makeTask("FN-2", "Nested Main Panel Task")],
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(),
refreshTasks: vi.fn(),
}));
await renderAppAndWait("board-view");
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Parent Main Panel Task" })).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("task-detail-open-nested"));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Nested Main Panel Task" })).toBeInTheDocument();
});
expect(window.history.pushState).toHaveBeenCalledTimes(2);
expect(dispatchNativeAndroidBack()).toBe(true);
expect(screen.getByRole("heading", { name: "Nested Main Panel Task" })).toBeInTheDocument();
dispatchPopState({ navIndex: 1 });
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Parent Main Panel Task" })).toBeInTheDocument();
});
expect(screen.queryByRole("heading", { name: "Nested Main Panel Task" })).toBeNull();
});
it("dismisses the board main-panel task detail on mobile popstate", async () => {
@@ -384,6 +478,80 @@ describe("Task detail mobile swipe-back", () => {
});
});
it("dismisses the list-mobile task detail on native Android Back", async () => {
const task = makeTask("FN-1", "Mobile List Detail");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
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(),
refreshTasks: vi.fn(),
}));
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
await renderAppAndWait("list-view");
fireEvent.click(screen.getByTestId("list-open-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument();
expect(screen.getByTestId("task-detail-mobile-header-mode")).toHaveTextContent("back");
});
expect(dispatchNativeAndroidBack()).toBe(true);
expect(window.history.back).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument();
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
expect(screen.getByTestId("list-view")).toBeInTheDocument();
});
});
it("does not swallow native Android Back when no Fusion nav entry exists", async () => {
await renderAppAndWait("board-view");
expect(dispatchNativeAndroidBack()).toBe(false);
expect(window.history.back).not.toHaveBeenCalled();
});
it("leaves desktop browser behavior unchanged for native Back events without task-detail history", async () => {
mockUseViewportMode.mockReturnValue("desktop");
const task = makeTask("FN-1", "Desktop List Detail");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
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(),
refreshTasks: vi.fn(),
}));
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
await renderAppAndWait("list-view");
fireEvent.click(screen.getByTestId("list-open-FN-1"));
expect(dispatchNativeAndroidBack()).toBe(false);
expect(window.history.pushState).not.toHaveBeenCalled();
expect(window.history.back).not.toHaveBeenCalled();
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
});
it("dismisses the list-mobile task detail on mobile popstate", async () => {
const task = makeTask("FN-1", "Mobile List Detail");
mockUseTasks.mockImplementation(() => ({
@@ -505,6 +673,52 @@ describe("Task detail mobile swipe-back", () => {
expect(screen.queryByRole("dialog", { name: "Nested Task" })).toBeNull();
});
it("consumes repeated native Android Back events while nested mobile detail entries exist", async () => {
const task = makeTask("FN-1", "Parent Task");
mockUseTasks.mockImplementation(() => ({
tasks: [task, makeTask("FN-2", "Nested Task")],
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(),
refreshTasks: vi.fn(),
}));
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
await renderAppAndWait("list-view");
fireEvent.click(screen.getByTestId("list-open-FN-1"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("task-detail-open-nested"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(true);
expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument();
dispatchPopState({ navIndex: 1 });
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(true);
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
expect(screen.getByTestId("list-view")).toBeInTheDocument();
});
expect(window.history.back).toHaveBeenCalledTimes(2);
});
it("pops multiple mobile detail entries back to the list target on a rapid Android-style pop", async () => {
const task = makeTask("FN-1", "Parent Task");
mockUseTasks.mockImplementation(() => ({

View File

@@ -41,6 +41,7 @@ export interface UseNavigationHistoryResult {
}
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
const FUSION_NATIVE_BACK_EVENT = "fusion:native-back";
export const NavigationHistoryContext = createContext<UseNavigationHistoryResult | null>(null);
@@ -173,6 +174,18 @@ export function useNavigationHistory(
useEffect(() => {
if (typeof window === "undefined") return;
/*
FNXC:TaskDetailAndroidBack 2026-06-29-20:40:
The mobile shell dispatches a cancelable native Back event before applying its fallback. Prevent it only when Fusion owns at least one nav entry, then route through history.back() so modal, nested, snapshot-only, hydrated, and main-panel task-detail dismissals reuse the same popstate stack semantics as browser Back and swipe-back.
*/
const handleNativeBack = (event: Event) => {
if (!enabledRef.current) return;
if (stackRef.current.length === 0) return;
event.preventDefault();
window.history.back();
};
const handlePopState = (event: PopStateEvent) => {
if (!enabledRef.current) return;
@@ -214,8 +227,10 @@ export function useNavigationHistory(
}
};
window.addEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack);
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack);
window.removeEventListener("popstate", handlePopState);
if (selfPopClearTimerRef.current !== null) {
window.clearTimeout(selfPopClearTimerRef.current);

View File

@@ -1,6 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildMobileShellHandoff } from "../plugins/shell-handoff.js";
type BackButtonListener = (event: { canGoBack: boolean }) => void;
const capacitorState = vi.hoisted(() => {
const state: {
isNativePlatform: ReturnType<typeof vi.fn>;
addListener: ReturnType<typeof vi.fn>;
backButtonRemove: ReturnType<typeof vi.fn>;
exitApp: ReturnType<typeof vi.fn>;
backButtonListener?: BackButtonListener;
} = {
isNativePlatform: vi.fn(() => false),
addListener: vi.fn(),
backButtonRemove: vi.fn(async () => {}),
exitApp: vi.fn(async () => {}),
backButtonListener: undefined,
};
state.addListener.mockImplementation(async (eventName: string, callback: BackButtonListener) => {
if (eventName === "backButton") {
state.backButtonListener = callback;
}
return { remove: state.backButtonRemove };
});
return state;
});
vi.mock("@capacitor/core", () => ({
Capacitor: {
isNativePlatform: capacitorState.isNativePlatform,
},
}));
vi.mock("@capacitor/app", () => ({
App: {
addListener: capacitorState.addListener,
exitApp: capacitorState.exitApp,
},
}));
const state = {
activeProfileId: null as string | null,
profiles: [] as Array<{ id: string; name: string; serverUrl: string; authToken?: string | null; createdAt: string; updatedAt: string; lastUsedAt?: string | null }>,
@@ -39,9 +79,16 @@ describe("MobileNativeShellBridge", () => {
state.activeProfileId = null;
state.profiles = [];
scanner.scanConnection.mockClear();
capacitorState.isNativePlatform.mockReturnValue(false);
capacitorState.backButtonListener = undefined;
vi.clearAllMocks();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("emits state updates to subscribers", async () => {
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
const bridge = new MobileNativeShellBridge(scanner as never);
@@ -110,4 +157,106 @@ describe("MobileNativeShellBridge", () => {
await expect(bridge.setDesktopMode("local")).rejects.toThrow("Desktop mode is not supported");
});
it("Android Back: skips Capacitor backButton registration on non-native platforms", async () => {
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
expect(capacitorState.isNativePlatform).toHaveBeenCalledTimes(1);
expect(capacitorState.addListener).not.toHaveBeenCalled();
expect(capacitorState.backButtonListener).toBeUndefined();
await manager.destroy();
expect(capacitorState.backButtonRemove).not.toHaveBeenCalled();
});
it("Android Back: registers and removes the native backButton listener on native platforms", async () => {
capacitorState.isNativePlatform.mockReturnValue(true);
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
expect(capacitorState.addListener).toHaveBeenCalledWith("backButton", expect.any(Function));
await manager.destroy();
expect(capacitorState.backButtonRemove).toHaveBeenCalledTimes(1);
});
it("Android Back: does not duplicate native listener registration across repeated initialization", async () => {
capacitorState.isNativePlatform.mockReturnValue(true);
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
await manager.initialize();
expect(capacitorState.addListener).toHaveBeenCalledTimes(1);
await manager.destroy();
expect(capacitorState.backButtonRemove).toHaveBeenCalledTimes(1);
});
it("Android Back: dispatches a cancelable browser event before native fallback", async () => {
capacitorState.isNativePlatform.mockReturnValue(true);
const mockWindow = new EventTarget() as Window & typeof globalThis;
const back = vi.fn();
Object.defineProperty(mockWindow, "history", {
value: { back },
configurable: true,
});
vi.stubGlobal("window", mockWindow);
const nativeBackListener = vi.fn((event: Event) => event.preventDefault());
mockWindow.addEventListener("fusion:native-back", nativeBackListener as EventListener);
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
capacitorState.backButtonListener?.({ canGoBack: false });
expect(nativeBackListener).toHaveBeenCalledTimes(1);
expect(back).not.toHaveBeenCalled();
expect(capacitorState.exitApp).not.toHaveBeenCalled();
});
it("Android Back: preserves browser-history fallback when Fusion does not handle it", async () => {
capacitorState.isNativePlatform.mockReturnValue(true);
const mockWindow = new EventTarget() as Window & typeof globalThis;
const back = vi.fn();
Object.defineProperty(mockWindow, "history", {
value: { back },
configurable: true,
});
vi.stubGlobal("window", mockWindow);
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
capacitorState.backButtonListener?.({ canGoBack: true });
expect(back).toHaveBeenCalledTimes(1);
expect(capacitorState.exitApp).not.toHaveBeenCalled();
});
it("Android Back: preserves native exit fallback when no history can go back", async () => {
capacitorState.isNativePlatform.mockReturnValue(true);
const mockWindow = new EventTarget() as Window & typeof globalThis;
Object.defineProperty(mockWindow, "history", {
value: { back: vi.fn() },
configurable: true,
});
vi.stubGlobal("window", mockWindow);
const { AndroidBackButtonManager } = await import("../plugins/native-shell.js");
const manager = new AndroidBackButtonManager();
await manager.initialize();
capacitorState.backButtonListener?.({ canGoBack: false });
expect(capacitorState.exitApp).toHaveBeenCalledTimes(1);
});
});

View File

@@ -21,7 +21,11 @@ export type {
PushNotificationManagerOptions,
} from "./plugins/push-notifications.js";
export { ShareManager } from "./plugins/share.js";
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
export {
AndroidBackButtonManager,
FUSION_NATIVE_BACK_EVENT,
MobileNativeShellBridge,
} from "./plugins/native-shell.js";
export { buildMobileShellHandoff } from "./plugins/shell-handoff.js";
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
export {
@@ -83,6 +87,7 @@ export function installMobileShellBridge(
): MobileNativeShellBridge {
const bridge = new MobileNativeShellBridge();
(target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge;
void bridge.initializeNativeBackButton();
return bridge;
}

View File

@@ -1,3 +1,4 @@
import { Capacitor } from "@capacitor/core";
import type {
FusionShellApi,
ShellConnectionProfile,
@@ -14,11 +15,111 @@ import {
import { QrScanner, type QrScanResult } from "./qr-scanner.js";
type Listener = (state: ShellConnectionState) => void;
type AppModule = typeof import("@capacitor/app");
type AppBackButtonListenerEvent = { canGoBack: boolean };
type AppListenerHandle = { remove: () => Promise<void> };
export const FUSION_NATIVE_BACK_EVENT = "fusion:native-back";
export class AndroidBackButtonManager {
private initialized = false;
private listenerHandle?: AppListenerHandle;
private appPlugin: AppModule["App"] | null = null;
/*
FNXC:TaskDetailAndroidBack 2026-06-29-20:40:
Native Android Back must first offer the dashboard's shared navigation-history stack a cancelable browser event so every task-detail surface dismisses through the same invariant as swipe/browser popstate. If the dashboard does not prevent the event, preserve Capacitor's native fallback by going back in ordinary browser history or exiting the app.
*/
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
this.initialized = true;
if (!Capacitor.isNativePlatform()) {
return;
}
const app = await this.loadAppPlugin();
if (!app) {
return;
}
this.listenerHandle = await app.addListener("backButton", (event) => {
this.handleBackButton(event);
});
}
async destroy(): Promise<void> {
if (this.listenerHandle) {
try {
await this.listenerHandle.remove();
} catch (error) {
console.warn("Failed to remove backButton listener", error);
}
this.listenerHandle = undefined;
}
this.initialized = false;
}
private handleBackButton(event: AppBackButtonListenerEvent): void {
if (this.dispatchNativeBackEvent()) {
return;
}
const win = globalThis.window;
if (event.canGoBack && win?.history && typeof win.history.back === "function") {
win.history.back();
return;
}
void this.appPlugin?.exitApp();
}
private dispatchNativeBackEvent(): boolean {
const win = globalThis.window;
if (!win || typeof win.dispatchEvent !== "function") {
return false;
}
const event = new CustomEvent(FUSION_NATIVE_BACK_EVENT, {
cancelable: true,
detail: { source: "android-back" },
});
win.dispatchEvent(event);
return event.defaultPrevented;
}
private async loadAppPlugin(): Promise<AppModule["App"] | null> {
try {
if (!this.appPlugin) {
this.appPlugin = (await import("@capacitor/app")).App;
}
return this.appPlugin;
} catch (error) {
console.warn("Failed to load Capacitor App plugin for Android Back", error);
return null;
}
}
}
export class MobileNativeShellBridge implements FusionShellApi {
private listeners = new Set<Listener>();
constructor(private readonly qrScanner: QrScanner = new QrScanner()) {}
constructor(
private readonly qrScanner: QrScanner = new QrScanner(),
private readonly androidBackButtonManager: AndroidBackButtonManager = new AndroidBackButtonManager(),
) {}
initializeNativeBackButton(): Promise<void> {
return this.androidBackButtonManager.initialize();
}
destroy(): Promise<void> {
return this.androidBackButtonManager.destroy();
}
private async buildState(): Promise<ShellConnectionState> {
const persisted = await loadShellProfiles();