Replace sleep(3000) with exponential backoff polling for trial activation#3705
Replace sleep(3000) with exponential backoff polling for trial activation#3705devin-ai-integration[bot] wants to merge 3 commits intomainfrom
Conversation
…tion - 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 <yujonglee.dev@gmail.com>
✅ Deploy Preview for hyprnote-storybook canceled.
|
✅ Deploy Preview for hyprnote canceled.
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…t 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 <yujonglee.dev@gmail.com>
… of polling outcome Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
| } | ||
| hasHandledRef.current = true; |
There was a problem hiding this comment.
🟡 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:
- First effect run:
hasHandledRef.currentisfalse→ set totrue,abortControllercreated,handle()starts async - Cleanup runs:
abortController.abort()fires - Second effect run:
hasHandledRef.currentistrue→ returns early, no newhandle()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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Replace sleep(3000) with exponential backoff polling for trial activation
Summary
After calling
POST /billing/start-trial, the client previously did a hardcodedsleep(3000)thenrefreshSession(). Stripe webhooks are unpredictable (sometimes >3s to propagate through webhook → stripe-sync-engine →stripe.active_entitlements→custom_access_token_hook), causing ~5% failure rate where the refreshed JWT still has empty entitlements.This replaces the sleep with exponential backoff polling that calls
refreshSession()and checks decoded JWT claims forhyprnote_proentitlement:poll-trial-activation.ts: Core polling loop — 1s initial delay, 1.5x backoff, 5s max, 10 attempts (~38s max total)useTrialActivation.ts: Shared hook wrapping the API call + polling + analytics, with AbortController cleanuponboarding/final.tsx: UsespollForTrialActivation()directly (has its own eligibility check +configureProSettingslogic)account.tsx: Uses theuseTrialActivation()hook, replacing the inlineuseMutationReview & Testing Checklist for Human
abortControllerclosure infinal.tsx: TheAbortControlleris declared at line 72 afterhandleis defined (line 40) but beforehandle()is invoked (line 73). Closure capture is correct but the ordering is slightly unusual — confirm this reads clearly.final.tsx) and the account settings "Start Pro Trial" button (account.tsx) need manual testing with a real Stripe integration to confirm the polling picks up the entitlement. Consider testing with network throttling to simulate slow webhook delivery.useTrialActivationhook firestrial_startedanalytics on success. The oldaccount.tsxinline mutation did the same. Thefinal.tsxpath fires analytics insidetryStartTrial()before polling starts (unchanged). Verify no double-firing or missing events.Notes
entitlements.includes("hyprnote_pro")which is populated bycustom_access_token_hookfromstripe.active_entitlements. This is the same check used byBillingProviderto determineisPro.Link to Devin run: https://app.devin.ai/sessions/877b8baff54c4989844f65a7651737e1
Requested by: @yujonglee