mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(ui): cancel superseded session route transitions
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user