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;
Comment on lines 40 to 41
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🟡 Onboarding gets stuck on loading screen in React StrictMode (dev mode)

In final.tsx, the combination of hasHandledRef guard and the abort cleanup causes the component to get permanently stuck in the loading state when React StrictMode is enabled.

Root Cause: StrictMode double-effect execution + hasHandledRef + abort early return

In React 18 StrictMode (which is enabled at apps/desktop/src/main.tsx:133), effects fire, cleanup, then fire again:

  1. First effect run: hasHandledRef.current is false → set to true, abortController created, handle() starts async
  2. Cleanup runs: abortController.abort() fires
  3. Second effect run: hasHandledRef.current is true → returns early, no new handle() is started

The handle() from step 1 is still executing asynchronously. When it reaches pollForTrialActivation, the signal is already aborted, so it returns { status: "aborted" }. Then at line 66:

if (result.status === "aborted") return;

This returns from handle() without calling setIsLoading(false) at line 75. Since the second effect run was blocked by hasHandledRef, no new handle() ever runs. The component is permanently stuck showing the loading spinner.

Impact: During development, the onboarding final step is completely broken — users see an infinite spinner. Production builds are unaffected since StrictMode is typically stripped.

(Refers to lines 38-41)

Prompt for agents
The hasHandledRef guard prevents re-execution after StrictMode cleanup aborts the first run. Two possible fixes:

1. Remove the hasHandledRef guard entirely and rely solely on the AbortController cleanup (the empty deps array already ensures the effect only runs once in production). In the cleanup, abort the controller, and in the second effect run, create a new controller and start handle() again. This is the idiomatic React 18 approach.

2. Alternatively, reset hasHandledRef.current = false in the cleanup function so the second effect invocation can re-run handle():

In final.tsx, change the cleanup at lines 80-82 to:
  return () => {
    abortController.abort();
    hasHandledRef.current = false;
  };

This allows the second StrictMode effect invocation to start a fresh handle() with a new AbortController.
Open in Devin Review

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


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
}, []);

if (isLoading) {
return (
Expand Down
45 changes: 6 additions & 39 deletions apps/desktop/src/components/settings/general/account.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Brain,
Cloud,
Expand All @@ -9,7 +9,7 @@ import {
} from "lucide-react";
import { type ReactNode, useCallback, useEffect, useState } from "react";

import { getRpcCanStartTrial, postBillingStartTrial } from "@hypr/api-client";
import { getRpcCanStartTrial } from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { type SubscriptionStatus } from "@hypr/plugin-auth";
Expand All @@ -22,6 +22,7 @@ import { cn } from "@hypr/utils";
import { useAuth } from "../../../auth";
import { useBillingAccess } from "../../../billing";
import { env } from "../../../env";
import { useTrialActivation } from "../../../hooks/useTrialActivation";

const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000";

Expand Down Expand Up @@ -333,41 +334,7 @@ function BillingButton() {
},
});

const startTrialMutation = useMutation({
mutationFn: async () => {
const headers = auth?.getHeaders();
if (!headers) {
throw new Error("Not authenticated");
}
const client = createClient({ baseUrl: env.VITE_API_URL, headers });
const { error } = await postBillingStartTrial({
client,
query: { interval: "monthly" },
});
if (error) {
throw error;
}

await new Promise((resolve) => setTimeout(resolve, 3000));
},
onSuccess: async () => {
void analyticsCommands.event({
event: "trial_started",
plan: "pro",
});
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + 14);
void analyticsCommands.setProperties({
email: auth?.session?.user.email,
user_id: auth?.session?.user.id,
set: {
plan: "pro",
trial_end_date: trialEndDate.toISOString(),
},
});
await auth?.refreshSession();
},
});
const { startTrial, isPending: isTrialPending } = useTrialActivation();

const handleProUpgrade = useCallback(() => {
void analyticsCommands.event({
Expand Down Expand Up @@ -401,8 +368,8 @@ function BillingButton() {
return (
<Button
variant="outline"
onClick={() => startTrialMutation.mutate()}
disabled={startTrialMutation.isPending}
onClick={() => startTrial()}
disabled={isTrialPending}
>
<span> Start Pro Trial</span>
</Button>
Expand Down
94 changes: 94 additions & 0 deletions apps/desktop/src/hooks/useTrialActivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";

import { postBillingStartTrial } from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";

import { useAuth } from "../auth";
import { env } from "../env";
import {
pollForTrialActivation,
type PollResult,
} from "../utils/poll-trial-activation";

type UseTrialActivationOptions = {
onActivated?: () => void;
onTimeout?: () => void;
onError?: (error: unknown) => void;
};

export function useTrialActivation(options: UseTrialActivationOptions = {}) {
const auth = useAuth();
const abortControllerRef = useRef<AbortController | null>(null);

useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);

const mutation = useMutation({
mutationFn: async (): Promise<PollResult> => {
const headers = auth?.getHeaders();
if (!headers) {
throw new Error("Not authenticated");
}

const client = createClient({ baseUrl: env.VITE_API_URL, headers });
const { error } = await postBillingStartTrial({
client,
query: { interval: "monthly" },
});
if (error) {
throw error;
}

abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;

return pollForTrialActivation({
refreshSession: () => auth.refreshSession(),
signal: abortController.signal,
});
},
onSuccess: (result) => {
if (result.status === "activated" || result.status === "timeout") {
void analyticsCommands.event({ event: "trial_started", plan: "pro" });
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + 14);
void analyticsCommands.setProperties({
email: auth?.session?.user.email,
user_id: auth?.session?.user.id,
set: {
plan: "pro",
trial_end_date: trialEndDate.toISOString(),
},
});
if (result.status === "activated") {
options.onActivated?.();
} else {
options.onTimeout?.();
}
}
},
onError: (error) => {
options.onError?.(error);
},
});

const cancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);

return {
startTrial: mutation.mutate,
startTrialAsync: mutation.mutateAsync,
isPending: mutation.isPending,
isError: mutation.isError,
error: mutation.error,
cancel,
};
}
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