fix(ui): cancel superseded session route transitions

This commit is contained in:
Amp
2026-08-21 05:30:54 +00:00
parent 579f9b8a8a
commit f4475d4ba6
5 changed files with 340 additions and 11 deletions
+3
View File
@@ -49,6 +49,7 @@ import { createNativeChatDrafts } from "./native-bridge.ts";
import { startNativeLinkRouting } from "./native-link-routing.ts";
import { createNativeNotificationsCapability } from "./native-notifications.ts";
import { createApplicationOverlays } from "./overlays.ts";
import { cancelActiveRouteTransition } from "./route-transition-owner.ts";
import { createApplicationPlacementStartup } from "./session-placement-startup.ts";
import {
loadSettings,
@@ -481,6 +482,7 @@ export function bootstrapApplication(
options: ApplicationNavigationOptions | undefined,
requested: "push" | "replace",
) => {
cancelActiveRouteTransition(document, { routeId, mode: requested });
const location = routeLocation(routeId, options);
// Preserve pre-start navigation exactly as the fire-and-forget entry point does.
if (!routerStarted) {
@@ -607,6 +609,7 @@ export function bootstrapApplication(
return startupLifecycle.run(steps);
},
stop: () => {
cancelActiveRouteTransition(document);
startupLifecycle.stop();
stopPostConnect();
agents.dispose();
+42
View File
@@ -0,0 +1,42 @@
import type { RouteId } from "../app-routes.ts";
export type RouteTransitionOwner = {
cancel: () => void;
isStartingNavigation: boolean;
target: RouteId;
};
const activeRouteTransitions = new WeakMap<Document, RouteTransitionOwner>();
export function claimActiveRouteTransition(
document: Document,
transition: RouteTransitionOwner,
): () => void {
activeRouteTransitions.get(document)?.cancel();
document.defaultView?.addEventListener("popstate", transition.cancel);
activeRouteTransitions.set(document, transition);
return () => {
document.defaultView?.removeEventListener("popstate", transition.cancel);
if (activeRouteTransitions.get(document) === transition) {
activeRouteTransitions.delete(document);
}
};
}
export function cancelActiveRouteTransition(
document: Document,
navigation?: { routeId: RouteId; mode: "push" | "replace" },
): void {
const transition = activeRouteTransitions.get(document);
// The transition's own same-target context.navigateAndWait call passes through
// this owner. Later same-target replacements only remove canonical route hints;
// another target or an unscoped shutdown is a new owner and must cancel.
if (
transition &&
navigation?.routeId === transition.target &&
(transition.isStartingNavigation || navigation.mode === "replace")
) {
return;
}
transition?.cancel();
}
+129
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cancelActiveRouteTransition } from "./route-transition-owner.ts";
import { CHAT_ROUTE_READY_EVENT, navigateWithRouteTransition } from "./route-transition.ts";
function testDocumentWithOutlet(animate = vi.fn()) {
@@ -141,4 +142,132 @@ describe("navigateWithRouteTransition", () => {
expect(test.animate).not.toHaveBeenCalled();
});
it("settles without animating when browser navigation supersedes a pending chat route", async () => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => undefined);
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: false,
});
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
let settled = false;
void transition.then(() => {
settled = true;
});
window.dispatchEvent(new PopStateEvent("popstate"));
await vi.waitFor(() => expect(settled).toBe(true));
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
expect(test.animate).not.toHaveBeenCalled();
});
it("cancels an active route animation when another destination takes ownership", async () => {
const finished = new Promise<Animation>(() => {});
const cancel = vi.fn();
const animation = { finished } as Animation;
animation.cancel = cancel;
const test = testDocumentWithOutlet(vi.fn(() => animation));
const navigate = vi.fn(async () => undefined);
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: false,
});
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await vi.waitFor(() => expect(test.animate).toHaveBeenCalledOnce());
cancelActiveRouteTransition(document, { routeId: "about", mode: "push" });
await transition;
expect(cancel).toHaveBeenCalledOnce();
});
it("honors a different route requested while transition navigation starts", async () => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => {
cancelActiveRouteTransition(document, { routeId: "about", mode: "push" });
});
await navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: false,
});
expect(navigate).toHaveBeenCalledOnce();
expect(test.animate).not.toHaveBeenCalled();
});
it("allows same-target replacements while the chat route finishes rendering", async () => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => undefined);
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: true,
});
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
let settled = false;
void transition.then(() => {
settled = true;
});
cancelActiveRouteTransition(document, { routeId: "chat", mode: "replace" });
await Promise.resolve();
expect(settled).toBe(false);
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await transition;
expect(test.animate).not.toHaveBeenCalled();
});
it("cancels a preparing transition when a newer transition takes ownership", async () => {
let finishPreparation!: () => void;
const firstNavigate = vi.fn(async () => undefined);
const first = navigateWithRouteTransition({
document,
from: "new-session",
to: "chat",
prepare: () =>
new Promise<void>((resolve) => {
finishPreparation = resolve;
}),
navigate: firstNavigate,
prefersReducedMotion: false,
});
await Promise.resolve();
const secondNavigate = vi.fn(async () => undefined);
const second = navigateWithRouteTransition({
document,
from: "new-session",
to: "chat",
navigate: secondNavigate,
prefersReducedMotion: true,
});
await vi.waitFor(() => expect(secondNavigate).toHaveBeenCalledOnce());
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await second;
let firstSettled = false;
void first.then(() => {
firstSettled = true;
});
await vi.waitFor(() => expect(firstSettled).toBe(true));
finishPreparation();
await Promise.resolve();
expect(firstNavigate).not.toHaveBeenCalled();
});
});
+89 -11
View File
@@ -1,4 +1,5 @@
import type { RouteId } from "../app-routes.ts";
import { claimActiveRouteTransition, type RouteTransitionOwner } from "./route-transition-owner.ts";
type RouteTransitionOptions = {
document: Document;
@@ -19,6 +20,59 @@ const SESSION_ROUTE_ENTER_OPTIONS: KeyframeAnimationOptions = {
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
};
type ActiveRouteTransition = RouteTransitionOwner & {
animation?: Animation;
canceled: Promise<void>;
isCanceled: boolean;
};
function createRouteTransition(target: RouteId): ActiveRouteTransition {
let resolveCanceled!: () => void;
const canceled = new Promise<void>((resolve) => {
resolveCanceled = resolve;
});
const transition: ActiveRouteTransition = {
canceled,
isCanceled: false,
isStartingNavigation: false,
target,
cancel: () => {
if (transition.isCanceled) {
return;
}
transition.isCanceled = true;
transition.animation?.cancel();
resolveCanceled();
},
};
return transition;
}
async function awaitRouteTransitionStep(
transition: ActiveRouteTransition,
step: Promise<unknown> | undefined,
): Promise<boolean> {
return Promise.race([
Promise.resolve(step).then(() => true),
transition.canceled.then(() => false),
]);
}
function startRouteNavigation(
transition: ActiveRouteTransition,
navigate: () => Promise<void>,
): Promise<void> {
if (transition.isCanceled) {
return Promise.resolve();
}
transition.isStartingNavigation = true;
try {
return navigate();
} finally {
transition.isStartingNavigation = false;
}
}
function waitForChatRouteReady(document: Document) {
if (document.querySelector(".agent-chat__composer-combobox")) {
return { cancel: () => undefined, ready: Promise.resolve() };
@@ -37,6 +91,7 @@ function waitForChatRouteReady(document: Document) {
async function navigateAndAnimate(
document: Document,
transition: ActiveRouteTransition,
navigate: () => Promise<void>,
prefersReducedMotion: boolean,
) {
@@ -45,9 +100,15 @@ async function navigateAndAnimate(
);
const chatReady = waitForChatRouteReady(document);
try {
await navigate();
await outlet?.updateComplete;
await chatReady.ready;
if (!(await awaitRouteTransitionStep(transition, startRouteNavigation(transition, navigate)))) {
return;
}
if (!(await awaitRouteTransitionStep(transition, outlet?.updateComplete))) {
return;
}
if (!(await awaitRouteTransitionStep(transition, chatReady.ready))) {
return;
}
} finally {
chatReady.cancel();
}
@@ -55,7 +116,15 @@ async function navigateAndAnimate(
return;
}
const animation = outlet?.animate?.(SESSION_ROUTE_ENTER_KEYFRAMES, SESSION_ROUTE_ENTER_OPTIONS);
await animation?.finished.catch(() => undefined);
if (transition.isCanceled) {
animation?.cancel();
} else {
transition.animation = animation;
}
await awaitRouteTransitionStep(
transition,
animation?.finished.catch(() => undefined),
);
}
export async function navigateWithRouteTransition(options: RouteTransitionOptions): Promise<void> {
@@ -64,13 +133,22 @@ export async function navigateWithRouteTransition(options: RouteTransitionOption
return navigate();
}
const transition = createRouteTransition(to);
const release = claimActiveRouteTransition(document, transition);
try {
await prepare?.();
} catch {
// Preparation is an enhancement. Preserve direct navigation so its normal
// route error handling remains authoritative when preloading fails.
return navigate();
}
try {
if (!(await awaitRouteTransitionStep(transition, prepare?.()))) {
return;
}
} catch {
// Preparation is an enhancement. Preserve direct navigation so its normal
// route error handling remains authoritative when preloading fails.
await awaitRouteTransitionStep(transition, startRouteNavigation(transition, navigate));
return;
}
return navigateAndAnimate(document, navigate, prefersReducedMotion);
await navigateAndAnimate(document, transition, navigate, prefersReducedMotion);
} finally {
release();
}
}
@@ -215,4 +215,81 @@ suite.define(() => {
await context.close();
}
});
it("keeps a newer user route when chat preparation completes late", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
let releaseChatModule!: () => void;
let chatModuleRequested = false;
const chatModuleBlocked = new Promise<void>((resolve) => {
releaseChatModule = resolve;
});
await page.route("**/assets/chat-page-*.js*", async (route) => {
chatModuleRequested = true;
await chatModuleBlocked;
await route.continue();
});
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": {
key: SESSION_KEY,
messageSeq: 1,
runId: RUN_ID,
runStarted: true,
},
"sessions.list": createdSessionListResult(SESSION_KEY),
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator(".new-session-page__message").fill("do not override my navigation");
await page.getByRole("button", { name: "Start session" }).click();
await gateway.waitForRequest("sessions.create");
await expect.poll(() => chatModuleRequested).toBe(true);
await captureProof(page, "04-transition-awaits-preload.png");
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.locator(".sidebar-identity-card").click();
await sidebar
.locator("wa-dropdown.sidebar-identity-menu")
.getByRole("menuitem", { exact: true, name: "Settings" })
.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/appearance");
await page.getByRole("heading", { name: "Settings" }).waitFor();
const chatModuleResponse = page.waitForResponse((response) =>
/\/assets\/chat-page-.*\.js/.test(new URL(response.url()).pathname),
);
releaseChatModule();
await chatModuleResponse;
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
expect(new URL(page.url()).pathname).toBe("/settings/appearance");
expect(await page.locator("openclaw-chat-page").count()).toBe(0);
expect(
await page.evaluate(() =>
document.getAnimations().some((animation) => {
const effect = animation.effect as KeyframeEffect | null;
return (
effect?.target instanceof HTMLElement &&
effect.target.tagName === "OPENCLAW-ROUTER-OUTLET"
);
}),
),
).toBe(false);
await captureProof(page, "05-superseded-transition-stays-on-settings.png");
} finally {
releaseChatModule();
await context.close();
}
});
});