Skip to content
Open
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
38 changes: 34 additions & 4 deletions src/browser/contexts/RouterContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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() {
Expand Down Expand Up @@ -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(
<RouterProvider>
<PathnameObserver />
</RouterProvider>
);

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"));
Expand Down Expand Up @@ -384,7 +413,6 @@ describe("standalone PWA startup", () => {

await waitFor(() => {
expect(view.getByTestId("pathname").textContent).toBe("/");
expect(window.sessionStorage.getItem("muxStandaloneSessionInitialized")).toBe("1");
});
});

Expand Down Expand Up @@ -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(
<RouterProvider>
Expand Down
84 changes: 55 additions & 29 deletions src/browser/contexts/RouterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 {
Expand Down Expand Up @@ -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<LaunchBehavior>(LAUNCH_BEHAVIOR_KEY, "dashboard")
: null;
const shouldIgnoreStandaloneWorkspaceUrl =
isStandalone && !hasStandaloneSession && window.location.pathname.startsWith("/workspace/");

if (window.location.protocol === "file:") {
const persistedRoute = readPersistedState<string | null>(LAST_VISITED_ROUTE_KEY, null);
Expand All @@ -161,21 +191,24 @@ 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 !== "/") {
if (!url.startsWith("/workspace/")) {
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;
}
}
Expand Down Expand Up @@ -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 (
<MemoryRouter initialEntries={[getInitialRoute()]} unstable_useTransitions={false}>
<RouterContextInner>{props.children}</RouterContextInner>
Expand Down
42 changes: 42 additions & 0 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const createWorkspaceMetadata = (
...overrides,
});

type NavigationType = "navigate" | "reload" | "back_forward" | "prerender";

describe("WorkspaceContext", () => {
afterEach(() => {
cleanup();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1566,6 +1598,7 @@ interface MockAPIOptions {
locationHash?: string;
locationPath?: string;
desktopMode?: boolean;
navigationType?: NavigationType;
pendingDeepLinks?: Array<{ type: string; [key: string]: unknown }>;
}

Expand Down Expand Up @@ -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<string, unknown> }).api = {
...(happyWindow as unknown as { api?: Record<string, unknown> }).api,
Expand Down
Loading