Skip to content

Commit 665f749

Browse files
committed
🤖 fix: restore the open chat on hard refresh
1 parent 99596c2 commit 665f749

3 files changed

Lines changed: 131 additions & 33 deletions

File tree

src/browser/contexts/RouterContext.test.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ function createMatchMedia(isStandalone = false): typeof window.matchMedia {
2525
}) satisfies MediaQueryList) as typeof window.matchMedia;
2626
}
2727

28-
function installWindow(url: string, options?: { isStandalone?: boolean }) {
28+
type NavigationType = "navigate" | "reload" | "back_forward" | "prerender";
29+
30+
function installWindow(
31+
url: string,
32+
options?: { isStandalone?: boolean; navigationType?: NavigationType }
33+
) {
2934
// Happy DOM can default to an opaque origin ("null") which breaks URL-based
3035
// logic in RouterContext. Give it a stable origin.
3136
const happyWindow = new GlobalWindow({ url });
@@ -34,6 +39,15 @@ function installWindow(url: string, options?: { isStandalone?: boolean }) {
3439
globalThis.window.matchMedia = createMatchMedia(options?.isStandalone);
3540
globalThis.window.localStorage.clear();
3641
globalThis.window.sessionStorage.clear();
42+
43+
const navigationEntries = [
44+
{ type: options?.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming,
45+
];
46+
Object.defineProperty(globalThis.window.performance, "getEntriesByType", {
47+
configurable: true,
48+
value: (entryType: string) =>
49+
entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [],
50+
});
3751
}
3852

3953
function PathnameObserver() {
@@ -138,6 +152,21 @@ describe("browser startup launch behavior", () => {
138152
});
139153
});
140154

155+
test("same-tab browser reload preserves a /workspace/:id URL in dashboard mode", async () => {
156+
installWindow("https://mux.example.com/workspace/reload-me", { navigationType: "reload" });
157+
window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("dashboard"));
158+
159+
const view = render(
160+
<RouterProvider>
161+
<PathnameObserver />
162+
</RouterProvider>
163+
);
164+
165+
await waitFor(() => {
166+
expect(view.getByTestId("pathname").textContent).toBe("/workspace/reload-me");
167+
});
168+
});
169+
141170
test("last-workspace mode preserves a /workspace/:id URL", async () => {
142171
installWindow("https://mux.example.com/workspace/stale-123");
143172
window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("last-workspace"));
@@ -384,7 +413,6 @@ describe("standalone PWA startup", () => {
384413

385414
await waitFor(() => {
386415
expect(view.getByTestId("pathname").textContent).toBe("/");
387-
expect(window.sessionStorage.getItem("muxStandaloneSessionInitialized")).toBe("1");
388416
});
389417
});
390418

@@ -426,8 +454,10 @@ describe("standalone PWA startup", () => {
426454
});
427455

428456
test("still restores the current route on reloads inside the same standalone window", async () => {
429-
installWindow("https://mux.example.com/workspace/reload-me", { isStandalone: true });
430-
window.sessionStorage.setItem("muxStandaloneSessionInitialized", "1");
457+
installWindow("https://mux.example.com/workspace/reload-me", {
458+
isStandalone: true,
459+
navigationType: "reload",
460+
});
431461

432462
const view = render(
433463
<RouterProvider>

src/browser/contexts/RouterContext.tsx

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function useRouter(): RouterContext {
6262
return ctx;
6363
}
6464

65-
const STANDALONE_PWA_SESSION_KEY = "muxStandaloneSessionInitialized";
65+
type StartupNavigationType = "navigate" | "reload" | "back_forward" | "prerender" | null;
6666

6767
function isStandalonePwa(): boolean {
6868
return (
@@ -71,20 +71,52 @@ function isStandalonePwa(): boolean {
7171
);
7272
}
7373

74-
function hasStandalonePwaSessionInitialized(): boolean {
75-
try {
76-
return window.sessionStorage.getItem(STANDALONE_PWA_SESSION_KEY) === "1";
77-
} catch {
78-
return false;
74+
function getStartupNavigationType(): StartupNavigationType {
75+
const entries = window.performance?.getEntriesByType?.("navigation");
76+
const firstEntry = entries?.[0];
77+
const entryType =
78+
firstEntry && typeof firstEntry === "object" && "type" in firstEntry ? firstEntry.type : null;
79+
80+
if (
81+
entryType === "navigate" ||
82+
entryType === "reload" ||
83+
entryType === "back_forward" ||
84+
entryType === "prerender"
85+
) {
86+
return entryType;
87+
}
88+
89+
const legacyType = window.performance?.navigation?.type;
90+
if (legacyType === 1) {
91+
return "reload";
7992
}
93+
if (legacyType === 2) {
94+
return "back_forward";
95+
}
96+
if (legacyType === 0) {
97+
return "navigate";
98+
}
99+
100+
return null;
80101
}
81102

82-
function markStandalonePwaSessionInitialized(): void {
83-
try {
84-
window.sessionStorage.setItem(STANDALONE_PWA_SESSION_KEY, "1");
85-
} catch {
86-
// If sessionStorage is unavailable, fall back to treating each load as a fresh launch.
103+
function isRouteRestoringNavigationType(type: StartupNavigationType): boolean {
104+
return type === "reload" || type === "back_forward";
105+
}
106+
107+
function shouldRestoreWorkspaceUrlOnStartup(options: {
108+
isStandalone: boolean;
109+
launchBehavior: LaunchBehavior | null;
110+
navigationType: StartupNavigationType;
111+
}): boolean {
112+
if (options.isStandalone) {
113+
return isRouteRestoringNavigationType(options.navigationType);
87114
}
115+
116+
return (
117+
options.launchBehavior === "last-workspace" ||
118+
isRouteRestoringNavigationType(options.navigationType)
119+
);
88120
}
89121

90122
function hasValidEncodedPathSegment(encodedValue: string): boolean {
@@ -147,12 +179,10 @@ function isRestorableRoute(route: unknown): route is string {
147179
function getInitialRoute(): string {
148180
const isStorybook = window.location.pathname.endsWith("iframe.html");
149181
const isStandalone = isStandalonePwa();
150-
const hasStandaloneSession = hasStandalonePwaSessionInitialized();
182+
const navigationType = getStartupNavigationType();
151183
const launchBehavior = !isStandalone
152184
? readPersistedState<LaunchBehavior>(LAUNCH_BEHAVIOR_KEY, "dashboard")
153185
: null;
154-
const shouldIgnoreStandaloneWorkspaceUrl =
155-
isStandalone && !hasStandaloneSession && window.location.pathname.startsWith("/workspace/");
156186

157187
if (window.location.protocol === "file:") {
158188
const persistedRoute = readPersistedState<string | null>(LAST_VISITED_ROUTE_KEY, null);
@@ -161,21 +191,24 @@ function getInitialRoute(): string {
161191
}
162192
}
163193

164-
// In browser mode (not Storybook), read route directly from URL (enables refresh restoration).
165-
// Standalone PWAs intentionally ignore stale workspace URLs on cold launch so opening the app
166-
// lands on the default root entrypoint, while preserving explicit deep links like /settings
167-
// or /project.
168-
if (window.location.protocol !== "file:" && !isStorybook && !shouldIgnoreStandaloneWorkspaceUrl) {
194+
// In browser mode (not Storybook), read route directly from the current URL. Workspace
195+
// routes are special: fresh launches may ignore them, but explicit restore-style navigations
196+
// such as hard reload/back-forward should reopen the same chat.
197+
if (window.location.protocol !== "file:" && !isStorybook) {
169198
const url = window.location.pathname + window.location.search;
170199
// Only use URL if it's a valid route (starts with /, not just "/" or empty)
171200
if (url.startsWith("/") && url !== "/") {
172201
if (!url.startsWith("/workspace/")) {
173202
return url;
174203
}
175204

176-
// Respect dashboard/new-chat launch preferences in browser mode so stale workspace URLs
177-
// do not silently override the user's chosen startup destination on a fresh launch.
178-
if (isStandalone || launchBehavior === "last-workspace") {
205+
if (
206+
shouldRestoreWorkspaceUrlOnStartup({
207+
isStandalone,
208+
launchBehavior,
209+
navigationType,
210+
})
211+
) {
179212
return url;
180213
}
181214
}
@@ -427,13 +460,6 @@ function RouterContextInner(props: { children: ReactNode }) {
427460
// causing a flash of stale UI between normal-priority updates (e.g.
428461
// setIsSending(false)) and the deferred route change.
429462
export function RouterProvider(props: { children: ReactNode }) {
430-
useEffect(() => {
431-
if (!isStandalonePwa()) return;
432-
// Mark the standalone session after commit so StrictMode's throwaway renders cannot
433-
// flip a cold launch into the reload path before the first real paint.
434-
markStandalonePwaSessionInitialized();
435-
}, []);
436-
437463
return (
438464
<MemoryRouter initialEntries={[getInitialRoute()]} unstable_useTransitions={false}>
439465
<RouterContextInner>{props.children}</RouterContextInner>

src/browser/contexts/WorkspaceContext.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const createWorkspaceMetadata = (
4848
...overrides,
4949
});
5050

51+
type NavigationType = "navigate" | "reload" | "back_forward" | "prerender";
52+
5153
describe("WorkspaceContext", () => {
5254
afterEach(() => {
5355
cleanup();
@@ -968,6 +970,36 @@ describe("WorkspaceContext", () => {
968970
expect(ctx().selectedWorkspace).toBeNull();
969971
});
970972

973+
test("browser reload restores the open workspace instead of reopening project creation", async () => {
974+
createMockAPI({
975+
workspace: {
976+
list: () =>
977+
Promise.resolve([
978+
createWorkspaceMetadata({
979+
id: "ws-open-chat",
980+
projectPath: "/existing",
981+
projectName: "existing",
982+
name: "main",
983+
namedWorkspacePath: "/existing-main",
984+
}),
985+
]),
986+
},
987+
localStorage: {
988+
[LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"),
989+
},
990+
locationPath: "/workspace/ws-open-chat",
991+
navigationType: "reload",
992+
});
993+
994+
const ctx = await setup();
995+
996+
await waitFor(() => expect(ctx().loading).toBe(false));
997+
await waitFor(() => {
998+
expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-open-chat");
999+
});
1000+
expect(ctx().pendingNewWorkspaceProject).toBeNull();
1001+
});
1002+
9711003
test("resolves system project route IDs for pending workspace creation", async () => {
9721004
const systemProjectPath = "/system/internal-project";
9731005
const systemProjectId = getProjectRouteId(systemProjectPath);
@@ -1566,6 +1598,7 @@ interface MockAPIOptions {
15661598
locationHash?: string;
15671599
locationPath?: string;
15681600
desktopMode?: boolean;
1601+
navigationType?: NavigationType;
15691602
pendingDeepLinks?: Array<{ type: string; [key: string]: unknown }>;
15701603
}
15711604

@@ -1596,6 +1629,15 @@ function createMockAPI(options: MockAPIOptions = {}) {
15961629
happyWindow.location.hash = options.locationHash;
15971630
}
15981631

1632+
const navigationEntries = [
1633+
{ type: options.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming,
1634+
];
1635+
Object.defineProperty(happyWindow.performance, "getEntriesByType", {
1636+
configurable: true,
1637+
value: (entryType: string) =>
1638+
entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [],
1639+
});
1640+
15991641
// Set up deep link API on the window object for pending deep-link tests
16001642
(happyWindow as unknown as { api?: Record<string, unknown> }).api = {
16011643
...(happyWindow as unknown as { api?: Record<string, unknown> }).api,

0 commit comments

Comments
 (0)