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
30 changes: 24 additions & 6 deletions apps/desktop/src/components/onboarding/final.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Route } from "../../routes/app/onboarding/_layout.index";
import * as settings from "../../store/tinybase/store/settings";
import { commands } from "../../types/tauri.gen";
import { configureProSettings } from "../../utils";
import { pollForTrialActivation } from "../../utils/poll-trial-activation";
import { getBack, type StepProps } from "./config";
import { OnboardingContainer } from "./shared";

Expand All @@ -26,6 +27,10 @@ export function Final({ onNavigate }: StepProps) {
const [isLoading, setIsLoading] = useState(true);
const [trialStarted, setTrialStarted] = useState(false);
const hasHandledRef = useRef(false);
const authRef = useRef(auth);
authRef.current = auth;
const storeRef = useRef(store);
storeRef.current = store;

const backStep = getBack(search);

Expand All @@ -35,25 +40,33 @@ export function Final({ onNavigate }: StepProps) {
}
hasHandledRef.current = true;

const abortController = new AbortController();

const handle = async () => {
if (!auth?.session) {
const currentAuth = authRef.current;
if (!currentAuth?.session) {
setIsLoading(false);
return;
}

const headers = auth.getHeaders();
const headers = currentAuth.getHeaders();
if (!headers) {
setIsLoading(false);
return;
}

try {
const started = await tryStartTrial(headers, store);
const started = await tryStartTrial(headers, storeRef.current);
setTrialStarted(started);
if (started) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const result = await pollForTrialActivation({
refreshSession: () => authRef.current.refreshSession(),
signal: abortController.signal,
});
if (result.status === "aborted") return;
} else {
await authRef.current.refreshSession();
}
await auth.refreshSession();
} catch (e) {
Sentry.captureException(e);
console.error(e);
Expand All @@ -63,7 +76,12 @@ export function Final({ onNavigate }: StepProps) {
};

void handle();
}, [auth, store]);

return () => {
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
Comment on lines 76 to +83
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 AbortController cleanup + hasHandledRef causes infinite loading in React StrictMode

In development mode with React StrictMode (enabled at apps/desktop/src/main.tsx:133), the component gets stuck on the loading screen forever.

Root Cause: StrictMode double-mount interacts badly with hasHandledRef + AbortController

React StrictMode unmounts and re-mounts components to surface side-effect bugs. The sequence is:

  1. First mount: Effect runs → hasHandledRef.current set to truehandle() starts async → cleanup function returned
  2. StrictMode unmount: Cleanup runs → abortController.abort() → the in-flight handle() will see the abort
  3. Second mount: Effect runs again → hasHandledRef.current is still true (refs persist across StrictMode remounts) → returns early without starting any work or returning a cleanup function

The handle() from step 1 reaches pollForTrialActivation which detects the aborted signal and returns { status: "aborted" }. Back in handle() at line 66, if (result.status === "aborted") return; causes it to return without calling setIsLoading(false). The second mount never starts a new handle() because hasHandledRef blocks it.

Result: isLoading remains true forever, and the user sees the spinner indefinitely.

Impact: Developers working on the onboarding flow in dev mode will be unable to proceed past the final step. This doesn't affect production builds since StrictMode effects only double-fire in development.

The fix should either: (a) remove hasHandledRef and rely solely on the AbortController cleanup (letting StrictMode re-run the effect properly on remount), or (b) reset hasHandledRef.current = false in the cleanup function so the second mount can re-execute.

(Refers to lines 38-83)

Prompt for agents
The hasHandledRef guard and AbortController cleanup are incompatible under React StrictMode. In StrictMode, the cleanup aborts the first execution, but hasHandledRef prevents the second execution from starting, leaving the user stuck on loading.

Two possible fixes:

Option A (recommended): Remove hasHandledRef entirely and rely on AbortController for cleanup. The effect will re-run on StrictMode remount, starting a fresh handle() with a new AbortController. The previous one is properly cleaned up via abort.

In apps/desktop/src/components/onboarding/final.tsx, remove lines 29 (hasHandledRef declaration), 38-40 (the guard and set), and keep the rest of the useEffect as-is. The AbortController cleanup already handles the double-mount case correctly.

Option B: Reset hasHandledRef in the cleanup function by adding hasHandledRef.current = false before abortController.abort() in the cleanup at line 80-82.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}, []);

if (isLoading) {
return (
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/utils/poll-trial-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Session } from "@supabase/supabase-js";

import { commands as authCommands } from "@hypr/plugin-auth";

const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 5000;
const BACKOFF_FACTOR = 1.5;
const MAX_ATTEMPTS = 10;

export type PollResult =
| { status: "activated"; session: Session }
| { status: "timeout" }
| { status: "aborted" };

type PollOptions = {
refreshSession: () => Promise<Session | null>;
signal?: AbortSignal;
};

export async function pollForTrialActivation(
options: PollOptions,
): Promise<PollResult> {
let delay = INITIAL_DELAY_MS;

for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
if (options.signal?.aborted) {
return { status: "aborted" };
}

try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
if (options.signal) {
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
options.signal.addEventListener("abort", onAbort, { once: true });
}
});
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") {
return { status: "aborted" };
}
throw e;
}

if (options.signal?.aborted) {
return { status: "aborted" };
}

try {
const session = await options.refreshSession();
if (session) {
const result = await authCommands.decodeClaims(session.access_token);
if (result.status === "ok") {
const entitlements = result.data.entitlements ?? [];
if (entitlements.includes("hyprnote_pro")) {
return { status: "activated", session };
}
}
}
} catch (error) {
console.warn(
`Trial activation poll attempt ${attempt + 1} failed:`,
error,
);
}

delay = Math.min(delay * BACKOFF_FACTOR, MAX_DELAY_MS);
}

return { status: "timeout" };
}
Loading