Skip to content

Commit 2202787

Browse files
fix(web): onboarding import no longer hangs when the project never arrives (#25805)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6bbb2d8 commit 2202787

2 files changed

Lines changed: 62 additions & 23 deletions

File tree

‎apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const state = vi.hoisted(() => ({
3535
onDone: vi.fn(),
3636
scan: vi.fn(),
3737
refreshScan: vi.fn(),
38+
waitForProject: vi.fn(),
3839
}));
3940

4041
vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => {
@@ -87,6 +88,7 @@ vi.mock(import("../../state/entities"), async (original) => {
8788
...(await original()),
8889
useProjects: (() => useAtomValue(state.projects as Atom.Atom<Project[]>)) as never,
8990
readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom<Project[]>)) as never,
91+
waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never,
9092
};
9193
});
9294
vi.mock(import("../../state/projects"), async (original) => ({
@@ -183,6 +185,27 @@ function recordProject({ environmentId, input }: CreateProjectInput) {
183185
return AsyncResult.success(undefined);
184186
}
185187

188+
// Resolves once the project reaches the mocked projects atom; the real helper's timeout is
189+
// replaced per test with a rejection.
190+
function waitForRecordedProject(ref: { environmentId: EnvironmentId; projectId: string }) {
191+
const find = () =>
192+
appAtomRegistry
193+
.get(projectsAtom())
194+
.find(
195+
(project) => project.id === ref.projectId && project.environmentId === ref.environmentId,
196+
);
197+
return new Promise<Project>((resolve) => {
198+
const current = find();
199+
if (current) return resolve(current);
200+
const unsubscribe = appAtomRegistry.subscribe(projectsAtom(), () => {
201+
const project = find();
202+
if (!project) return;
203+
unsubscribe();
204+
resolve(project);
205+
});
206+
});
207+
}
208+
186209
function deferred<T>() {
187210
let resolve!: (value: T) => void;
188211
const promise = new Promise<T>((complete) => {
@@ -255,6 +278,7 @@ function setup() {
255278
state.onDone.mockReset();
256279
state.scan.mockReset();
257280
state.refreshScan.mockReset();
281+
state.waitForProject.mockReset().mockImplementation(waitForRecordedProject);
258282
return now;
259283
}
260284

@@ -304,3 +328,23 @@ it("completes accepted imports when permission is revoked after the final server
304328
expect(state.completeOnboarding).toHaveBeenCalledOnce();
305329
expect(state.onDone).toHaveBeenCalledOnce();
306330
});
331+
332+
it("finishes setup without landing when the imported project never reaches the client", async () => {
333+
using _now = setup();
334+
state.candidates = state.candidates.slice(0, 1);
335+
// The server accepts the project, but it never shows up in the client's projects.
336+
state.createProject.mockResolvedValue(AsyncResult.success(undefined));
337+
state.waitForProject.mockRejectedValue(new Error("The project did not appear."));
338+
state.completeOnboarding.mockRejectedValueOnce(new Error("offline"));
339+
await using _screen = await mountImport();
340+
341+
await click("Import 1 project");
342+
await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce());
343+
// A failed completion hands the step back instead of leaving it importing.
344+
await vi.waitFor(() => expect(button("Import 1 project").disabled).toBe(false));
345+
expect(state.onDone).not.toHaveBeenCalled();
346+
347+
await click("Import 1 project");
348+
await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
349+
expect(state.onDone).toHaveBeenCalledWith(undefined);
350+
});

‎apps/web/src/components/onboarding/WelcomeWizard.tsx‎

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
} from "../../onboarding/providerReadiness.logic";
5858
import { newProjectId, randomUUID } from "../../lib/utils";
5959
import { agentSessionImport } from "../../state/agentSessions";
60-
import { readProjects, useProjects } from "../../state/entities";
60+
import { readProjects, waitForProject } from "../../state/entities";
6161
import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
6262
import {
6363
isOnboardingRelayEnvironment,
@@ -1186,11 +1186,9 @@ function ImportStep({
11861186
const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
11871187
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
11881188
const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1189-
const projects = useProjects();
11901189
const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
11911190
const importWarningRef = useRef("");
11921191
const importedThreadCountRef = useRef(0);
1193-
const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
11941192
const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
11951193
const projectsWithImportedHistoryRef = useRef(new Map<string, ScopedProjectRef>());
11961194
const lastImportSelectionRef = useRef<ReadonlyArray<string>>([]);
@@ -1206,25 +1204,6 @@ function ImportStep({
12061204
};
12071205
}, []);
12081206

1209-
useEffect(() => {
1210-
if (
1211-
landingProject !== null &&
1212-
projects.some(
1213-
(project) =>
1214-
project.id === landingProject.projectId &&
1215-
project.environmentId === landingProject.environmentId,
1216-
)
1217-
) {
1218-
// oxlint-disable-next-line react/set-state-in-effect -- The landing project is known only once the projects subscription delivers it.
1219-
setLandingProject(null);
1220-
void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then(
1221-
(completed) => {
1222-
if (!completed) setIsImporting(false);
1223-
},
1224-
);
1225-
}
1226-
}, [landingProject, onDone, projects, setIsImporting]);
1227-
12281207
const { available: candidates, recent } = partitionOnboardingProjects(
12291208
scans.flatMap((scan) =>
12301209
(scan.data?.candidates ?? []).map((candidate) => ({
@@ -1258,7 +1237,23 @@ function ImportStep({
12581237
return;
12591238
}
12601239
setIsImporting(true);
1261-
setLandingProject(projectRef);
1240+
const importGeneration = importGenerationRef.current;
1241+
// waitForProject gives up after its timeout, so a project that never reaches the client
1242+
// still finishes setup, just without landing in it.
1243+
void waitForProject(projectRef)
1244+
.then(
1245+
() => projectRef,
1246+
() => undefined,
1247+
)
1248+
.then(async (landing) => {
1249+
if (importGeneration !== importGenerationRef.current) return;
1250+
const completed = await onDone(
1251+
landing,
1252+
importWarningRef.current,
1253+
importedThreadCountRef.current,
1254+
);
1255+
if (!completed) setIsImporting(false);
1256+
});
12621257
};
12631258

12641259
const runImport = async (selection: typeof candidates) => {

0 commit comments

Comments
 (0)