Files
multica/apps/web/platform/navigation.tsx
Bohan Jiang ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:25 +08:00

71 lines
2.2 KiB
TypeScript

"use client";
import { Suspense, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { canGoBackInApp } from "./in-app-history";
/**
* Web half of the `multica:navigate` bridge — the event shared content
* (comments, chat, issue descriptions) fires when a link resolves to an in-app
* destination. Desktop's shell answers it by opening a tab; on the web the
* equivalent is a router push in place. Without this the event has no listener
* and such links do nothing at all.
*/
function useInternalLinkHandler(router: ReturnType<typeof useRouter>) {
useEffect(() => {
const handler = (e: Event) => {
const path = (e as CustomEvent<{ path?: string }>).detail?.path;
if (!path) return;
router.push(path);
};
window.addEventListener("multica:navigate", handler);
return () => window.removeEventListener("multica:navigate", handler);
}, [router]);
}
function NavigationProviderInner({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
useInternalLinkHandler(router);
const adapter: NavigationAdapter = {
push: router.push,
replace: router.replace,
back: router.back,
canGoBack: canGoBackInApp,
pathname,
searchParams: new URLSearchParams(searchParams.toString()),
getShareableUrl: (path: string) =>
typeof window === "undefined" ? path : window.location.origin + path,
// router.prefetch is a no-op in dev mode by Next.js design; in production
// it warms the RSC payload + route chunk so the next push() commits with
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
prefetch: (path: string) => {
router.prefetch(path);
},
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
export function WebNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense>
<NavigationProviderInner>{children}</NavigationProviderInner>
</Suspense>
);
}