diff --git a/src/browser/contexts/RouterContext.test.tsx b/src/browser/contexts/RouterContext.test.tsx index dc6aab5f68..3ec65ea232 100644 --- a/src/browser/contexts/RouterContext.test.tsx +++ b/src/browser/contexts/RouterContext.test.tsx @@ -25,7 +25,12 @@ function createMatchMedia(isStandalone = false): typeof window.matchMedia { }) satisfies MediaQueryList) as typeof window.matchMedia; } -function installWindow(url: string, options?: { isStandalone?: boolean }) { +type NavigationType = "navigate" | "reload" | "back_forward" | "prerender"; + +function installWindow( + url: string, + options?: { isStandalone?: boolean; navigationType?: NavigationType } +) { // Happy DOM can default to an opaque origin ("null") which breaks URL-based // logic in RouterContext. Give it a stable origin. const happyWindow = new GlobalWindow({ url }); @@ -34,6 +39,15 @@ function installWindow(url: string, options?: { isStandalone?: boolean }) { globalThis.window.matchMedia = createMatchMedia(options?.isStandalone); globalThis.window.localStorage.clear(); globalThis.window.sessionStorage.clear(); + + const navigationEntries = [ + { type: options?.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming, + ]; + Object.defineProperty(globalThis.window.performance, "getEntriesByType", { + configurable: true, + value: (entryType: string) => + entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [], + }); } function PathnameObserver() { @@ -138,6 +152,21 @@ describe("browser startup launch behavior", () => { }); }); + test("same-tab browser reload preserves a /workspace/:id URL in dashboard mode", async () => { + installWindow("https://mux.example.com/workspace/reload-me", { navigationType: "reload" }); + window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("dashboard")); + + const view = render( + + + + ); + + await waitFor(() => { + expect(view.getByTestId("pathname").textContent).toBe("/workspace/reload-me"); + }); + }); + test("last-workspace mode preserves a /workspace/:id URL", async () => { installWindow("https://mux.example.com/workspace/stale-123"); window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("last-workspace")); @@ -384,7 +413,6 @@ describe("standalone PWA startup", () => { await waitFor(() => { expect(view.getByTestId("pathname").textContent).toBe("/"); - expect(window.sessionStorage.getItem("muxStandaloneSessionInitialized")).toBe("1"); }); }); @@ -426,8 +454,10 @@ describe("standalone PWA startup", () => { }); test("still restores the current route on reloads inside the same standalone window", async () => { - installWindow("https://mux.example.com/workspace/reload-me", { isStandalone: true }); - window.sessionStorage.setItem("muxStandaloneSessionInitialized", "1"); + installWindow("https://mux.example.com/workspace/reload-me", { + isStandalone: true, + navigationType: "reload", + }); const view = render( diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx index 444964f075..2c002301b0 100644 --- a/src/browser/contexts/RouterContext.tsx +++ b/src/browser/contexts/RouterContext.tsx @@ -62,7 +62,7 @@ export function useRouter(): RouterContext { return ctx; } -const STANDALONE_PWA_SESSION_KEY = "muxStandaloneSessionInitialized"; +type StartupNavigationType = "navigate" | "reload" | "back_forward" | "prerender" | null; function isStandalonePwa(): boolean { return ( @@ -71,20 +71,52 @@ function isStandalonePwa(): boolean { ); } -function hasStandalonePwaSessionInitialized(): boolean { - try { - return window.sessionStorage.getItem(STANDALONE_PWA_SESSION_KEY) === "1"; - } catch { - return false; +function getStartupNavigationType(): StartupNavigationType { + const entries = window.performance?.getEntriesByType?.("navigation"); + const firstEntry = entries?.[0]; + const entryType = + firstEntry && typeof firstEntry === "object" && "type" in firstEntry ? firstEntry.type : null; + + if ( + entryType === "navigate" || + entryType === "reload" || + entryType === "back_forward" || + entryType === "prerender" + ) { + return entryType; + } + + const legacyType = window.performance?.navigation?.type; + if (legacyType === 1) { + return "reload"; } + if (legacyType === 2) { + return "back_forward"; + } + if (legacyType === 0) { + return "navigate"; + } + + return null; } -function markStandalonePwaSessionInitialized(): void { - try { - window.sessionStorage.setItem(STANDALONE_PWA_SESSION_KEY, "1"); - } catch { - // If sessionStorage is unavailable, fall back to treating each load as a fresh launch. +function isRouteRestoringNavigationType(type: StartupNavigationType): boolean { + return type === "reload" || type === "back_forward"; +} + +function shouldRestoreWorkspaceUrlOnStartup(options: { + isStandalone: boolean; + launchBehavior: LaunchBehavior | null; + navigationType: StartupNavigationType; +}): boolean { + if (options.isStandalone) { + return isRouteRestoringNavigationType(options.navigationType); } + + return ( + options.launchBehavior === "last-workspace" || + isRouteRestoringNavigationType(options.navigationType) + ); } function hasValidEncodedPathSegment(encodedValue: string): boolean { @@ -147,12 +179,10 @@ function isRestorableRoute(route: unknown): route is string { function getInitialRoute(): string { const isStorybook = window.location.pathname.endsWith("iframe.html"); const isStandalone = isStandalonePwa(); - const hasStandaloneSession = hasStandalonePwaSessionInitialized(); + const navigationType = getStartupNavigationType(); const launchBehavior = !isStandalone ? readPersistedState(LAUNCH_BEHAVIOR_KEY, "dashboard") : null; - const shouldIgnoreStandaloneWorkspaceUrl = - isStandalone && !hasStandaloneSession && window.location.pathname.startsWith("/workspace/"); if (window.location.protocol === "file:") { const persistedRoute = readPersistedState(LAST_VISITED_ROUTE_KEY, null); @@ -161,11 +191,10 @@ function getInitialRoute(): string { } } - // In browser mode (not Storybook), read route directly from URL (enables refresh restoration). - // Standalone PWAs intentionally ignore stale workspace URLs on cold launch so opening the app - // lands on the default root entrypoint, while preserving explicit deep links like /settings - // or /project. - if (window.location.protocol !== "file:" && !isStorybook && !shouldIgnoreStandaloneWorkspaceUrl) { + // In browser mode (not Storybook), read route directly from the current URL. Workspace + // routes are special: fresh launches may ignore them, but explicit restore-style navigations + // such as hard reload/back-forward should reopen the same chat. + if (window.location.protocol !== "file:" && !isStorybook) { const url = window.location.pathname + window.location.search; // Only use URL if it's a valid route (starts with /, not just "/" or empty) if (url.startsWith("/") && url !== "/") { @@ -173,9 +202,13 @@ function getInitialRoute(): string { return url; } - // Respect dashboard/new-chat launch preferences in browser mode so stale workspace URLs - // do not silently override the user's chosen startup destination on a fresh launch. - if (isStandalone || launchBehavior === "last-workspace") { + if ( + shouldRestoreWorkspaceUrlOnStartup({ + isStandalone, + launchBehavior, + navigationType, + }) + ) { return url; } } @@ -427,13 +460,6 @@ function RouterContextInner(props: { children: ReactNode }) { // causing a flash of stale UI between normal-priority updates (e.g. // setIsSending(false)) and the deferred route change. export function RouterProvider(props: { children: ReactNode }) { - useEffect(() => { - if (!isStandalonePwa()) return; - // Mark the standalone session after commit so StrictMode's throwaway renders cannot - // flip a cold launch into the reload path before the first real paint. - markStandalonePwaSessionInitialized(); - }, []); - return ( {props.children} diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 01fb904a6d..a2719341c6 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -48,6 +48,8 @@ const createWorkspaceMetadata = ( ...overrides, }); +type NavigationType = "navigate" | "reload" | "back_forward" | "prerender"; + describe("WorkspaceContext", () => { afterEach(() => { cleanup(); @@ -968,6 +970,36 @@ describe("WorkspaceContext", () => { expect(ctx().selectedWorkspace).toBeNull(); }); + test("browser reload restores the open workspace instead of reopening project creation", async () => { + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-open-chat", + projectPath: "/existing", + projectName: "existing", + name: "main", + namedWorkspacePath: "/existing-main", + }), + ]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"), + }, + locationPath: "/workspace/ws-open-chat", + navigationType: "reload", + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + await waitFor(() => { + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-open-chat"); + }); + expect(ctx().pendingNewWorkspaceProject).toBeNull(); + }); + test("resolves system project route IDs for pending workspace creation", async () => { const systemProjectPath = "/system/internal-project"; const systemProjectId = getProjectRouteId(systemProjectPath); @@ -1566,6 +1598,7 @@ interface MockAPIOptions { locationHash?: string; locationPath?: string; desktopMode?: boolean; + navigationType?: NavigationType; pendingDeepLinks?: Array<{ type: string; [key: string]: unknown }>; } @@ -1596,6 +1629,15 @@ function createMockAPI(options: MockAPIOptions = {}) { happyWindow.location.hash = options.locationHash; } + const navigationEntries = [ + { type: options.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming, + ]; + Object.defineProperty(happyWindow.performance, "getEntriesByType", { + configurable: true, + value: (entryType: string) => + entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [], + }); + // Set up deep link API on the window object for pending deep-link tests (happyWindow as unknown as { api?: Record }).api = { ...(happyWindow as unknown as { api?: Record }).api,