Skip to content

Replace sleep(3000) with exponential backoff polling for trial activation#3705

Open
devin-ai-integration[bot] wants to merge 3 commits intomainfrom
devin/1770469282-exponential-backoff-trial-polling
Open

Replace sleep(3000) with exponential backoff polling for trial activation#3705
devin-ai-integration[bot] wants to merge 3 commits intomainfrom
devin/1770469282-exponential-backoff-trial-polling

Conversation

@devin-ai-integration
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot commented Feb 7, 2026

Replace sleep(3000) with exponential backoff polling for trial activation

Summary

After calling POST /billing/start-trial, the client previously did a hardcoded sleep(3000) then refreshSession(). Stripe webhooks are unpredictable (sometimes >3s to propagate through webhook → stripe-sync-engine → stripe.active_entitlementscustom_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 for hyprnote_pro entitlement:

  • 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 cleanup
  • onboarding/final.tsx: Uses pollForTrialActivation() directly (has its own eligibility check + configureProSettings logic)
  • account.tsx: Uses the useTrialActivation() hook, replacing the inline useMutation

Review & Testing Checklist for Human

  • Verify onboarding flow timeout UX: When polling times out (all 10 attempts fail), the onboarding shows "You're all set!" anyway. The trial IS started on Stripe — it's just the JWT that hasn't caught up. Confirm this is acceptable behavior (user will get Pro on next natural token refresh).
  • Verify abortController closure in final.tsx: The AbortController is declared at line 72 after handle is defined (line 40) but before handle() is invoked (line 73). Closure capture is correct but the ordering is slightly unusual — confirm this reads clearly.
  • Test both paths end-to-end: The onboarding flow (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.
  • Confirm analytics parity: The useTrialActivation hook fires trial_started analytics on success. The old account.tsx inline mutation did the same. The final.tsx path fires analytics inside tryStartTrial() before polling starts (unchanged). Verify no double-firing or missing events.

Notes

  • The polling checks entitlements.includes("hyprnote_pro") which is populated by custom_access_token_hook from stripe.active_entitlements. This is the same check used by BillingProvider to determine isPro.
  • No unit tests added — this is a timing/integration issue that's hard to test without mocking the full Stripe webhook flow.

Link to Devin run: https://app.devin.ai/sessions/877b8baff54c4989844f65a7651737e1
Requested by: @yujonglee


Open with Devin

…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>
@netlify
Copy link

netlify bot commented Feb 7, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit 02a16d0
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/6987459337b9e800084aef1a

@netlify
Copy link

netlify bot commented Feb 7, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit 02a16d0
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/698745938fe20a0008908ef2

@devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

…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>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

… of polling outcome

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines 40 to 41
}
hasHandledRef.current = true;
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.

@yujonglee yujonglee mentioned this pull request Feb 8, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant