fix(ux): resolve Soulseek search spinner wedge and onboarding 'already taken' dead-end

- Soulseek search relied solely on an SSE 'complete' event to clear its
  spinner; if that event was dropped (connection blip, backend never emits it)
  the search UI spun forever. Add a 45s fallback that force-completes the
  search so the user sees whatever results arrived; late results still stream
  in via the store subscription.
- Onboarding's 'username already taken' path told the user to refresh, which
  can't recover the half-created account (the token never persisted). Instead
  attempt a login with the same credentials and continue: resume at step 2 if
  onboarding is unfinished, route home if already complete, or send to the
  normal sign-in for a 2FA account. A genuine password mismatch now gets a
  clear 'sign in instead' message rather than a dead end.
This commit is contained in:
chevron7
2026-06-15 09:00:14 -05:00
parent 84dc5a934d
commit 8f1239709c
2 changed files with 33 additions and 3 deletions
+21 -3
View File
@@ -104,9 +104,27 @@ export default function OnboardingPage() {
const message = err instanceof Error ? err.message : String(err);
// Check if user already exists
if (message?.includes("already taken")) {
setError(
"Username already taken. If this is you, please refresh and continue where you left off.",
);
// Usually a refresh/retry race: the account was created but the
// token never persisted client-side. Rather than dead-end on a
// "refresh" instruction that can't recover the session, try
// logging in with the same credentials and continue.
try {
const user = await api.login(username, password);
if (user.requires2FA) {
router.push("/login");
return;
}
if (user.onboardingComplete) {
router.push("/");
return;
}
setStep(2);
return;
} catch {
setError(
"That username already exists and the password didn't match. If it's your account, sign in instead.",
);
}
} else {
setError(message || "Failed to create account");
}
@@ -124,6 +124,18 @@ export function useSoulseekSearch({
};
}, [query, soulseekEnabled]);
// Fallback completion: the SSE "complete" event can be dropped (connection
// blip, or the backend never emits it). Without a terminal signal
// isSoulseekPolling stays true and the search UI spins forever. Force the
// search complete after a ceiling so the user sees whatever results arrived
// instead of an endless spinner; late results still stream in via the store
// subscription, only the spinner is resolved.
useEffect(() => {
if (!hasActiveSearch || isComplete) return;
const timeout = setTimeout(() => setIsComplete(true), 45000);
return () => clearTimeout(timeout);
}, [hasActiveSearch, isComplete]);
const handleDownload = useCallback(async (result: SoulseekResult) => {
const downloadKey = `${result.username}:${result.path}`;
try {