Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions desktop/src/app/navigation/navigationGuard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";

const target = {
kind: "channel-message",
channelId: "general",
messageId: "message-a",
threadRootId: "thread-a",
};

const { allowNavigation, registerNavigationGuard, traverseHistory } =
await import("./navigationGuard.ts");

test("all navigation consults the registered boundary guard", () => {
let received;
const unregister = registerNavigationGuard((nextTarget) => {
received = nextTarget;
return false;
});

assert.equal(allowNavigation(target), false);
assert.deepEqual(received, target);
unregister();
assert.equal(allowNavigation(target), true);
});

test("guarded history traversal blocks before mutating history", () => {
let received;
let backCalls = 0;
const unregister = registerNavigationGuard((nextTarget) => {
received = nextTarget;
return false;
});

assert.equal(
traverseHistory(
{
back: () => {
backCalls += 1;
},
forward: () => {},
},
"back",
),
false,
);
assert.deepEqual(received, { kind: "history", direction: "back" });
assert.equal(backCalls, 0);
unregister();
});

test("guarded history traversal invokes the selected direction when allowed", () => {
let forwardCalls = 0;

assert.equal(
traverseHistory(
{
back: () => {},
forward: () => {
forwardCalls += 1;
},
},
"forward",
),
true,
);
assert.equal(forwardCalls, 1);
});

test("unregistering the newer guard restores the prior live guard", () => {
const unregisterFirst = registerNavigationGuard(() => false);
const unregisterSecond = registerNavigationGuard(() => true);

assert.equal(allowNavigation(target), true);
unregisterSecond();
assert.equal(allowNavigation(target), false);
unregisterFirst();
assert.equal(allowNavigation(target), true);
});

test("stale cleanup cannot unregister a newer guard", () => {
const unregisterFirst = registerNavigationGuard(() => false);
const unregisterSecond = registerNavigationGuard(() => true);

unregisterFirst();
assert.equal(allowNavigation(target), true);
unregisterSecond();
assert.equal(allowNavigation(target), true);
});

test("duplicate callback registrations clean up by registration identity", () => {
const sharedGuard = () => false;
const unregisterFirst = registerNavigationGuard(sharedGuard);
const unregisterSecond = registerNavigationGuard(sharedGuard);

unregisterFirst();
assert.equal(allowNavigation(target), false);
unregisterSecond();
assert.equal(allowNavigation(target), true);
});
54 changes: 54 additions & 0 deletions desktop/src/app/navigation/navigationGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export type GuardedNavigation =
| {
kind: "history";
direction: "back" | "forward";
}
| {
kind: "route";
href: string;
}
| {
kind: "channel-message";
channelId: string;
messageId: string;
threadRootId: string | null;
}
| {
kind: "forum-post";
channelId: string;
postId: string;
replyId: string | null;
};

type NavigationGuard = (target: GuardedNavigation) => boolean;

type GuardRegistration = {
guard: NavigationGuard;
};

const activeGuards: GuardRegistration[] = [];

export function allowNavigation(target: GuardedNavigation): boolean {
return activeGuards.at(-1)?.guard(target) ?? true;
}

export function traverseHistory(
history: Pick<History, "back" | "forward">,
direction: "back" | "forward",
): boolean {
if (!allowNavigation({ kind: "history", direction })) {
return false;
}

history[direction]();
return true;
}

export function registerNavigationGuard(guard: NavigationGuard): () => void {
const registration = { guard };
activeGuards.push(registration);
return () => {
const index = activeGuards.lastIndexOf(registration);
if (index >= 0) activeGuards.splice(index, 1);
};
}
48 changes: 39 additions & 9 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
} from "@tanstack/react-router";

import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import {
allowNavigation,
type GuardedNavigation,
traverseHistory,
} from "@/app/navigation/navigationGuard";
import type { SearchHit } from "@/shared/api/types";

type NavigationBehavior = {
Expand All @@ -30,13 +35,22 @@ export function useAppNavigation() {
state?: Record<string, unknown>;
},
behavior: NavigationBehavior = {},
guardedTarget?: GuardedNavigation,
) => {
const nextLocation = router.buildLocation(next as never);

if (location.href === nextLocation.href && !behavior.force) {
return false;
}

if (
!allowNavigation(
guardedTarget ?? { kind: "route", href: nextLocation.href },
)
) {
return false;
}

await navigate({
...next,
replace: behavior.replace,
Expand Down Expand Up @@ -256,8 +270,8 @@ export function useAppNavigation() {
thread?: string;
threadRootId?: string | null;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId",
params: {
Expand All @@ -282,7 +296,16 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: options?.messageId ? true : undefined,
},
),
options?.messageId
? {
kind: "channel-message",
channelId,
messageId: options.messageId,
threadRootId: options.threadRootId ?? null,
}
: undefined,
);
},
[commitNavigation],
);

Expand All @@ -307,8 +330,8 @@ export function useAppNavigation() {
replace?: boolean;
replyId?: string;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId/posts/$postId",
params: {
Expand All @@ -322,7 +345,14 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: false,
},
),
{
kind: "forum-post",
channelId,
postId,
replyId: options?.replyId ?? null,
},
);
},
[commitNavigation],
);

Expand All @@ -340,7 +370,7 @@ export function useAppNavigation() {

const closeSettings = React.useCallback(() => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}

Expand All @@ -349,7 +379,7 @@ export function useAppNavigation() {

const closeWorkflowDetail = React.useCallback(() => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}

Expand All @@ -359,7 +389,7 @@ export function useAppNavigation() {
const closeForumPost = React.useCallback(
(channelId: string) => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}

Expand Down
5 changes: 3 additions & 2 deletions desktop/src/app/navigation/useBackForwardControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
import { traverseHistory } from "@/app/navigation/navigationGuard";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";

Expand Down Expand Up @@ -59,15 +60,15 @@ export function useBackForwardControls() {
return;
}

router.history.back();
traverseHistory(router.history, "back");
}, [canGoBack, router.history]);

const goForward = React.useCallback(() => {
if (!canGoForward) {
return;
}

router.history.forward();
traverseHistory(router.history, "forward");
}, [canGoForward, router.history]);

const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/app/routes/WorkflowsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function WorkflowsRouteScreen({
onEditorPaneChange,
}: WorkflowsRouteScreenProps) {
const {
closeWorkflowDetail,
goDuplicateWorkflow,
goEditWorkflow,
goNewWorkflow,
Expand All @@ -26,11 +27,11 @@ export function WorkflowsRouteScreen({
} = useAppNavigation();
const closeEditor = React.useCallback(() => {
if (editor?.hasOrigin) {
window.history.back();
closeWorkflowDetail();
return;
}
void goWorkflows({ replace: true });
}, [editor?.hasOrigin, goWorkflows]);
}, [closeWorkflowDetail, editor?.hasOrigin, goWorkflows]);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
Expand Down
Loading
Loading