Skip to content

Commit c9b5ffa

Browse files
authored
🤖 fix: prevent workspace create selection race (#1239)
Fixes a race where the server-mode launch-project auto-selection could win over an in-progress workspace creation, leaving the UI in the wrong workspace state. Changes: - Cancel in-flight launch-project selection when creation starts - Centralize WorkspaceSelection construction via toWorkspaceSelection() - Remove redundant selection reset in useStartWorkspaceCreation - Add regression test for launch-project vs pending creation Test: - make static-check --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_
1 parent 3d7305f commit c9b5ffa

File tree

4 files changed

+99
-59
lines changed

4 files changed

+99
-59
lines changed

src/browser/App.tsx

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useCallback, useRef } from "react";
22
import "./styles/globals.css";
3-
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
3+
import { useWorkspaceContext, toWorkspaceSelection } from "./contexts/WorkspaceContext";
44
import { useProjectContext } from "./contexts/ProjectContext";
55
import type { WorkspaceSelection } from "./components/ProjectSidebar";
66
import { LeftSidebar } from "./components/LeftSidebar";
@@ -114,7 +114,6 @@ function AppInner() {
114114
const startWorkspaceCreation = useStartWorkspaceCreation({
115115
projects,
116116
beginWorkspaceCreation,
117-
setSelectedWorkspace,
118117
});
119118

120119
useEffect(() => {
@@ -203,12 +202,7 @@ function AppInner() {
203202
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
204203
// Old localStorage entry missing namedWorkspacePath - update it once
205204
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
206-
setSelectedWorkspace({
207-
workspaceId: metadata.id,
208-
projectPath: metadata.projectPath,
209-
projectName: metadata.projectName,
210-
namedWorkspacePath: metadata.namedWorkspacePath,
211-
});
205+
setSelectedWorkspace(toWorkspaceSelection(metadata));
212206
}
213207
}
214208
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
@@ -274,12 +268,7 @@ function AppInner() {
274268
const targetMetadata = sortedWorkspaces[targetIndex];
275269
if (!targetMetadata) return;
276270

277-
setSelectedWorkspace({
278-
projectPath: selectedWorkspace.projectPath,
279-
projectName: selectedWorkspace.projectName,
280-
namedWorkspacePath: targetMetadata.namedWorkspacePath,
281-
workspaceId: targetMetadata.id,
282-
});
271+
setSelectedWorkspace(toWorkspaceSelection(targetMetadata));
283272
},
284273
[selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace]
285274
);
@@ -587,12 +576,7 @@ function AppInner() {
587576
});
588577

589578
// Switch to the new workspace
590-
setSelectedWorkspace({
591-
workspaceId: workspaceInfo.id,
592-
projectPath: workspaceInfo.projectPath,
593-
projectName: workspaceInfo.projectName,
594-
namedWorkspacePath: workspaceInfo.namedWorkspacePath,
595-
});
579+
setSelectedWorkspace(toWorkspaceSelection(workspaceInfo));
596580
};
597581

598582
window.addEventListener(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, handleForkSwitch as EventListener);
@@ -706,12 +690,7 @@ function AppInner() {
706690
// User has already selected another workspace - don't override
707691
return current;
708692
}
709-
return {
710-
workspaceId: metadata.id,
711-
projectPath: metadata.projectPath,
712-
projectName: metadata.projectName,
713-
namedWorkspacePath: metadata.namedWorkspacePath,
714-
};
693+
return toWorkspaceSelection(metadata);
715694
});
716695

717696
// Track telemetry

src/browser/contexts/WorkspaceContext.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,59 @@ describe("WorkspaceContext", () => {
506506
expect(ctx().selectedWorkspace?.projectPath).toBe("/existing");
507507
});
508508

509+
test("launch project does not override pending workspace creation", async () => {
510+
// Race condition test: if user starts creating a workspace while
511+
// getLaunchProject is in flight, the launch project should not override
512+
513+
let resolveLaunchProject: (value: string | null) => void;
514+
const launchProjectPromise = new Promise<string | null>((resolve) => {
515+
resolveLaunchProject = resolve;
516+
});
517+
518+
const initialWorkspaces = [
519+
createWorkspaceMetadata({
520+
id: "ws-launch",
521+
projectPath: "/launch-project",
522+
projectName: "launch-project",
523+
name: "main",
524+
namedWorkspacePath: "/launch-project-main",
525+
}),
526+
];
527+
528+
createMockAPI({
529+
workspace: {
530+
list: () => Promise.resolve(initialWorkspaces),
531+
},
532+
server: {
533+
getLaunchProject: () => launchProjectPromise,
534+
},
535+
});
536+
537+
const ctx = await setup();
538+
539+
await waitFor(() => expect(ctx().loading).toBe(false));
540+
541+
// User starts workspace creation (this sets pendingNewWorkspaceProject)
542+
act(() => {
543+
ctx().beginWorkspaceCreation("/new-project");
544+
});
545+
546+
// Verify pending state is set
547+
expect(ctx().pendingNewWorkspaceProject).toBe("/new-project");
548+
expect(ctx().selectedWorkspace).toBeNull();
549+
550+
// Now the launch project response arrives
551+
await act(async () => {
552+
resolveLaunchProject!("/launch-project");
553+
// Give effect time to process
554+
await new Promise((r) => setTimeout(r, 50));
555+
});
556+
557+
// Should NOT have selected the launch project workspace because creation is pending
558+
expect(ctx().selectedWorkspace).toBeNull();
559+
expect(ctx().pendingNewWorkspaceProject).toBe("/new-project");
560+
});
561+
509562
test("WorkspaceProvider calls ProjectContext.refreshProjects after loading", async () => {
510563
// Verify that projects.list is called during workspace metadata loading
511564
const projectsListMock = mock(() => Promise.resolve([]));

src/browser/contexts/WorkspaceContext.tsx

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat
5959
}
6060
}
6161

62+
export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection {
63+
return {
64+
workspaceId: metadata.id,
65+
projectPath: metadata.projectPath,
66+
projectName: metadata.projectName,
67+
namedWorkspacePath: metadata.namedWorkspacePath,
68+
};
69+
}
70+
6271
/**
6372
* Ensure workspace metadata has createdAt timestamp.
6473
* DEFENSIVE: Backend guarantees createdAt, but default to 2025-01-01 if missing.
@@ -209,12 +218,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
209218

210219
if (metadata) {
211220
// Restore from hash (overrides localStorage)
212-
setSelectedWorkspace({
213-
workspaceId: metadata.id,
214-
projectPath: metadata.projectPath,
215-
projectName: metadata.projectName,
216-
namedWorkspacePath: metadata.namedWorkspacePath,
217-
});
221+
setSelectedWorkspace(toWorkspaceSelection(metadata));
218222
}
219223
} else if (hash.length > 1) {
220224
// Try to interpret hash as project path (for direct deep linking)
@@ -228,12 +232,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
228232

229233
if (projectWorkspaces.length > 0) {
230234
const metadata = projectWorkspaces[0];
231-
setSelectedWorkspace({
232-
workspaceId: metadata.id,
233-
projectPath: metadata.projectPath,
234-
projectName: metadata.projectName,
235-
namedWorkspacePath: metadata.namedWorkspacePath,
236-
});
235+
setSelectedWorkspace(toWorkspaceSelection(metadata));
237236
}
238237
}
239238
// Only run once when loading finishes
@@ -248,40 +247,53 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
248247
// Skip if we already have a selected workspace (from localStorage or URL hash)
249248
if (selectedWorkspace) return;
250249

250+
// Skip if user is in the middle of creating a workspace
251+
if (pendingNewWorkspaceProject) return;
252+
253+
let cancelled = false;
254+
251255
const checkLaunchProject = async () => {
252256
// Only available in server mode (checked via platform/capabilities in future)
253257
// For now, try the call - it will return null if not applicable
254258
try {
255259
const launchProjectPath = await api.server.getLaunchProject(undefined);
256-
if (!launchProjectPath) return;
260+
if (cancelled || !launchProjectPath) return;
257261

258262
// Find first workspace in this project
259263
const projectWorkspaces = Array.from(workspaceMetadata.values()).filter(
260264
(meta) => meta.projectPath === launchProjectPath
261265
);
262266

263-
if (projectWorkspaces.length > 0) {
264-
// Select the first workspace in the project
265-
const metadata = projectWorkspaces[0];
266-
setSelectedWorkspace({
267-
workspaceId: metadata.id,
268-
projectPath: metadata.projectPath,
269-
projectName: metadata.projectName,
270-
namedWorkspacePath: metadata.namedWorkspacePath,
271-
});
272-
}
267+
if (cancelled || projectWorkspaces.length === 0) return;
268+
269+
// Select the first workspace in the project.
270+
// Use functional update to avoid race: user may have clicked a workspace
271+
// while this async call was in flight.
272+
const metadata = projectWorkspaces[0];
273+
setSelectedWorkspace((current) => current ?? toWorkspaceSelection(metadata));
273274
} catch (error) {
274-
// Ignore errors (e.g. method not found if running against old backend)
275-
console.debug("Failed to check launch project:", error);
275+
if (!cancelled) {
276+
// Ignore errors (e.g. method not found if running against old backend)
277+
console.debug("Failed to check launch project:", error);
278+
}
276279
}
277280
// If no workspaces exist yet, just leave the project in the sidebar
278281
// The user will need to create a workspace
279282
};
280283

281284
void checkLaunchProject();
282-
// Only run once when loading finishes or selectedWorkspace changes
283-
// eslint-disable-next-line react-hooks/exhaustive-deps
284-
}, [loading, selectedWorkspace]);
285+
286+
return () => {
287+
cancelled = true;
288+
};
289+
}, [
290+
api,
291+
loading,
292+
selectedWorkspace,
293+
pendingNewWorkspaceProject,
294+
workspaceMetadata,
295+
setSelectedWorkspace,
296+
]);
285297

286298
// Subscribe to metadata updates (for create/rename/delete operations)
287299
useEffect(() => {

src/browser/hooks/useStartWorkspaceCreation.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useCallback, useEffect } from "react";
22
import type { ProjectConfig } from "@/node/config";
3-
import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar";
43
import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events";
54
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
65
import {
@@ -53,7 +52,6 @@ export function persistWorkspaceCreationPrefill(
5352
interface UseStartWorkspaceCreationOptions {
5453
projects: Map<string, ProjectConfig>;
5554
beginWorkspaceCreation: (projectPath: string) => void;
56-
setSelectedWorkspace: (selection: WorkspaceSelection | null) => void;
5755
}
5856

5957
function resolveProjectPath(
@@ -70,7 +68,6 @@ function resolveProjectPath(
7068
export function useStartWorkspaceCreation({
7169
projects,
7270
beginWorkspaceCreation,
73-
setSelectedWorkspace,
7471
}: UseStartWorkspaceCreationOptions) {
7572
const startWorkspaceCreation = useCallback(
7673
(projectPath: string, detail?: StartWorkspaceCreationDetail) => {
@@ -83,9 +80,8 @@ export function useStartWorkspaceCreation({
8380

8481
persistWorkspaceCreationPrefill(resolvedProjectPath, detail);
8582
beginWorkspaceCreation(resolvedProjectPath);
86-
setSelectedWorkspace(null);
8783
},
88-
[projects, beginWorkspaceCreation, setSelectedWorkspace]
84+
[projects, beginWorkspaceCreation]
8985
);
9086

9187
useEffect(() => {

0 commit comments

Comments
 (0)