From c99f13295bef81ce5141cbd0af71bb2c2d214260 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:09:11 +0000 Subject: [PATCH 1/3] Replace sleep(3000) with exponential backoff polling for trial activation - Add pollForTrialActivation() utility with exponential backoff (1s initial, 1.5x factor, 5s max, 10 attempts) - Add useTrialActivation() shared hook for both onboarding and settings - Update onboarding/final.tsx to poll instead of sleeping after trial start - Update settings/general/account.tsx to use shared hook - Add AbortController cleanup on component unmount Co-Authored-By: yujonglee --- .../src/components/onboarding/final.tsx | 15 ++- .../components/settings/general/account.tsx | 45 ++------- apps/desktop/src/hooks/useTrialActivation.ts | 92 +++++++++++++++++++ .../src/utils/poll-trial-activation.ts | 74 +++++++++++++++ 4 files changed, 185 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/hooks/useTrialActivation.ts create mode 100644 apps/desktop/src/utils/poll-trial-activation.ts diff --git a/apps/desktop/src/components/onboarding/final.tsx b/apps/desktop/src/components/onboarding/final.tsx index 4558e0b368..3486b4c57c 100644 --- a/apps/desktop/src/components/onboarding/final.tsx +++ b/apps/desktop/src/components/onboarding/final.tsx @@ -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"; @@ -51,9 +52,14 @@ export function Final({ onNavigate }: StepProps) { const started = await tryStartTrial(headers, store); setTrialStarted(started); if (started) { - await new Promise((resolve) => setTimeout(resolve, 3000)); + const result = await pollForTrialActivation({ + refreshSession: () => auth.refreshSession(), + signal: abortController.signal, + }); + if (result.status === "aborted") return; + } else { + await auth.refreshSession(); } - await auth.refreshSession(); } catch (e) { Sentry.captureException(e); console.error(e); @@ -62,7 +68,12 @@ export function Final({ onNavigate }: StepProps) { setIsLoading(false); }; + const abortController = new AbortController(); void handle(); + + return () => { + abortController.abort(); + }; }, [auth, store]); if (isLoading) { diff --git a/apps/desktop/src/components/settings/general/account.tsx b/apps/desktop/src/components/settings/general/account.tsx index f85865624d..230b76efca 100644 --- a/apps/desktop/src/components/settings/general/account.tsx +++ b/apps/desktop/src/components/settings/general/account.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Brain, Cloud, @@ -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"; @@ -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"; @@ -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({ @@ -401,8 +368,8 @@ function BillingButton() { return ( diff --git a/apps/desktop/src/hooks/useTrialActivation.ts b/apps/desktop/src/hooks/useTrialActivation.ts new file mode 100644 index 0000000000..df46f0101b --- /dev/null +++ b/apps/desktop/src/hooks/useTrialActivation.ts @@ -0,0 +1,92 @@ +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(null); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const mutation = useMutation({ + mutationFn: async (): Promise => { + 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") { + 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(), + }, + }); + options.onActivated?.(); + } else if (result.status === "timeout") { + 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, + }; +} diff --git a/apps/desktop/src/utils/poll-trial-activation.ts b/apps/desktop/src/utils/poll-trial-activation.ts new file mode 100644 index 0000000000..d82c9d1289 --- /dev/null +++ b/apps/desktop/src/utils/poll-trial-activation.ts @@ -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; + signal?: AbortSignal; +}; + +export async function pollForTrialActivation( + options: PollOptions, +): Promise { + let delay = INITIAL_DELAY_MS; + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + if (options.signal?.aborted) { + return { status: "aborted" }; + } + + try { + await new Promise((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" }; +} From bb5284d37e268db86c90391714efa3583f660694 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:36:59 +0000 Subject: [PATCH 2/3] Fix useEffect dependency bug: use refs for auth/store to prevent abort on refreshSession refreshSession() updates auth context, which re-triggers useEffect cleanup and aborts the in-flight poll. Using refs stabilizes the closure so the effect only runs once on mount. Co-Authored-By: yujonglee --- .../src/components/onboarding/final.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/onboarding/final.tsx b/apps/desktop/src/components/onboarding/final.tsx index 3486b4c57c..ab66919c66 100644 --- a/apps/desktop/src/components/onboarding/final.tsx +++ b/apps/desktop/src/components/onboarding/final.tsx @@ -27,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); @@ -36,29 +40,32 @@ 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) { const result = await pollForTrialActivation({ - refreshSession: () => auth.refreshSession(), + refreshSession: () => authRef.current.refreshSession(), signal: abortController.signal, }); if (result.status === "aborted") return; } else { - await auth.refreshSession(); + await authRef.current.refreshSession(); } } catch (e) { Sentry.captureException(e); @@ -68,13 +75,13 @@ export function Final({ onNavigate }: StepProps) { setIsLoading(false); }; - const abortController = new AbortController(); void handle(); return () => { abortController.abort(); }; - }, [auth, store]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); if (isLoading) { return ( From 02a16d040376a0e76550ac550cfd2d77009d1c34 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:00:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Fire=20analytics=20on=20timeout=20too=20?= =?UTF-8?q?=E2=80=94=20trial=20is=20started=20on=20Stripe=20regardless=20o?= =?UTF-8?q?f=20polling=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: yujonglee --- apps/desktop/src/hooks/useTrialActivation.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/hooks/useTrialActivation.ts b/apps/desktop/src/hooks/useTrialActivation.ts index df46f0101b..8a250b84bb 100644 --- a/apps/desktop/src/hooks/useTrialActivation.ts +++ b/apps/desktop/src/hooks/useTrialActivation.ts @@ -54,7 +54,7 @@ export function useTrialActivation(options: UseTrialActivationOptions = {}) { }); }, onSuccess: (result) => { - if (result.status === "activated") { + if (result.status === "activated" || result.status === "timeout") { void analyticsCommands.event({ event: "trial_started", plan: "pro" }); const trialEndDate = new Date(); trialEndDate.setDate(trialEndDate.getDate() + 14); @@ -66,9 +66,11 @@ export function useTrialActivation(options: UseTrialActivationOptions = {}) { trial_end_date: trialEndDate.toISOString(), }, }); - options.onActivated?.(); - } else if (result.status === "timeout") { - options.onTimeout?.(); + if (result.status === "activated") { + options.onActivated?.(); + } else { + options.onTimeout?.(); + } } }, onError: (error) => {