-
Notifications
You must be signed in to change notification settings - Fork 35
fix(react-ui): make each OAuth flow own the popup it opened #2014
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| --- | ||
| "@crossmint/client-sdk-react-ui": patch | ||
| --- | ||
|
|
||
| Fixes the OAuth popup flow losing logins when a second provider is clicked. | ||
|
|
||
| All providers share one named popup (`PopupWindow.initEmpty` opens `window.open(..., "popupWindow")`), so a second click wraps the window a first flow is still using in a client of its own. The flow resumes at three points after handing that popup over: the URL await, the auth-material refresh await, and the 2.5s closure poller. Each flow now claims the popup and re-checks the claim at every one of them, so a superseded flow can no longer close the popup, publish its error, or clear the loading state belonging to the flow that replaced it. | ||
|
|
||
| Two teardown-ordering fixes come with it: | ||
|
|
||
| - Listener teardown happens when a new flow takes the popup over, not at the top of the call. Doing it earlier left a still-live popup with nothing listening across the URL await, dropping a login that had already completed. | ||
| - The closure poller no longer removes listeners. The callback page posts its one-time secret and then closes itself, so a tick landing in that gap dropped the material. Takeover and unmount both remove them, so at most one pair is ever alive. | ||
|
|
||
| Unmounting mid-flow now closes the popup it opened, instead of leaving a window on screen that nothing will ever close. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { useState, useCallback } from "react"; | ||
| import { useEffect, useRef, useState, useCallback } from "react"; | ||
| import type { OAuthProvider } from "@crossmint/common-sdk-auth"; | ||
| import { PopupWindow } from "@crossmint/client-sdk-window"; | ||
| import { useCrossmintAuth } from "@/hooks"; | ||
|
|
@@ -10,9 +10,33 @@ export const useOAuthWindowListener = (oauthUrlMap: OAuthUrlMap, setError: (erro | |
| const { crossmintAuth } = useCrossmintAuth(); | ||
| // Track which OAuth provider's window is currently being interacted with | ||
| const [activeOAuthProvider, setActiveOAuthProvider] = useState<OAuthProvider | null>(null); | ||
| const cleanupRef = useRef<(() => void) | null>(null); | ||
| const popupRef = useRef<PopupWindow<IncomingEvents, OutgoingEvents> | null>(null); | ||
| // Every click claims the next id. Because all flows share one named popup, each resumption | ||
| // point below has to check it still holds the claim before touching the popup or the state. | ||
| const flowIdRef = useRef(0); | ||
| const mountedRef = useRef(true); | ||
|
|
||
| useEffect(() => { | ||
| mountedRef.current = true; | ||
|
|
||
| return () => { | ||
| mountedRef.current = false; | ||
| cleanupRef.current?.(); | ||
| cleanupRef.current = null; | ||
| // Nothing is left to adopt this popup, so abort the flow instead of leaving a window | ||
| // on screen that no longer has a listener to close it. | ||
| popupRef.current?.window?.close(); | ||
| popupRef.current = null; | ||
| }; | ||
| }, []); | ||
|
|
||
| const createPopupAndSetupListeners = useCallback( | ||
| async (provider: OAuthProvider, providerLoginHint?: string) => { | ||
| // Claim the flow before the first await. | ||
| const flowId = ++flowIdRef.current; | ||
| const ownsPopup = () => flowIdRef.current === flowId && mountedRef.current; | ||
|
|
||
| setActiveOAuthProvider(provider); | ||
| setError(null); | ||
|
|
||
|
|
@@ -27,6 +51,7 @@ export const useOAuthWindowListener = (oauthUrlMap: OAuthUrlMap, setError: (erro | |
| height: 700, | ||
| incomingEvents, | ||
| }); | ||
| popupRef.current = popup; | ||
|
|
||
| const prefetchedUrl = oauthUrlMap[provider]; | ||
| const resolvedUrl = prefetchedUrl || (await crossmintAuth?.getOAuthUrl(provider)); | ||
|
|
@@ -35,9 +60,17 @@ export const useOAuthWindowListener = (oauthUrlMap: OAuthUrlMap, setError: (erro | |
| } | ||
| baseUrl = new URL(resolvedUrl); | ||
| } catch (e) { | ||
| popup?.window?.close(); | ||
| setActiveOAuthProvider(null); | ||
| setError(e instanceof Error ? e.message : "Failed to start OAuth login"); | ||
| if (ownsPopup()) { | ||
| popup?.window?.close(); | ||
| setActiveOAuthProvider(null); | ||
| setError(e instanceof Error ? e.message : "Failed to start OAuth login"); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // PopupWindow.initEmpty opens a named window, so the later click reused this | ||
| // popup. Leave it to that flow rather than closing it or navigating it again. | ||
| if (!ownsPopup()) { | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -59,26 +92,33 @@ export const useOAuthWindowListener = (oauthUrlMap: OAuthUrlMap, setError: (erro | |
| }); | ||
| } | ||
|
|
||
| // Drop the previous flow's listeners only now that this one is taking the popup over. | ||
| // Doing it before the await above would leave its still-live popup with nothing listening. | ||
| cleanupRef.current?.(); | ||
|
|
||
| if (popup.window != null) { | ||
| popup.window.location.href = baseUrl.toString(); | ||
| } | ||
|
|
||
| // Listen on the popup itself: it is the window that sends these events, and its | ||
| // transport drops anything from another sender. | ||
| let stopListening = () => { | ||
| // reassigned below, once there is something to unsubscribe | ||
| }; | ||
|
|
||
| // transport drops anything from another sender. Every flow wraps the same reused | ||
| // window in its own client, so the takeover above is what keeps one pair alive. | ||
| const handleAuthMaterial = async (data: { oneTimeSecret: string }) => { | ||
| await crossmintAuth?.handleRefreshAuthMaterial(data.oneTimeSecret); | ||
| stopListening(); | ||
| if (!ownsPopup()) { | ||
| return; | ||
| } | ||
| cleanup(); | ||
| popup.window?.close(); | ||
| setActiveOAuthProvider(null); | ||
| }; | ||
|
|
||
| const handleError = (data: { error: string }) => { | ||
| if (!ownsPopup()) { | ||
| return; | ||
| } | ||
| setError(data.error); | ||
| stopListening(); | ||
| cleanup(); | ||
| popup.window?.close(); | ||
| setActiveOAuthProvider(null); | ||
| }; | ||
|
|
@@ -87,18 +127,24 @@ export const useOAuthWindowListener = (oauthUrlMap: OAuthUrlMap, setError: (erro | |
| const errorListenerId = popup.on("errorFromPopupCallback", handleError); | ||
| // Add a check for manual window closure | ||
| // Ideally we should find a more explicit way of doing this, but I think this is fine for now. | ||
| // The listeners deliberately stay registered: a callback page posts its secret and then closes | ||
| // itself, so tearing them down here would drop material that is already in flight. Takeover | ||
| // and unmount both remove them, so at most one pair is ever alive. | ||
| const checkWindowClosure = setInterval(() => { | ||
| if (popup.window?.closed) { | ||
| stopListening(); | ||
| setActiveOAuthProvider(null); | ||
| clearInterval(checkWindowClosure); | ||
| if (ownsPopup()) { | ||
| setActiveOAuthProvider(null); | ||
| } | ||
| } | ||
|
Comment on lines
133
to
139
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After the user manually closes the popup, this branch clears only Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/client/ui/react-ui/src/hooks/useOAuthWindowListener.ts
Line: 139-145
Comment:
**Closed popup poller stays active**
After the user manually closes the popup, this branch clears only `activeOAuthProvider`; the interval, closed popup reference, and message listeners remain alive until another flow starts or the provider unmounts, causing recurring wakeups and retained resources for the remaining provider lifetime.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| }, 2500); // Check every 2.5 seconds | ||
|
|
||
| stopListening = () => { | ||
| const cleanup = () => { | ||
| clearInterval(checkWindowClosure); | ||
| popup.off(authMaterialListenerId); | ||
| popup.off(errorListenerId); | ||
| clearInterval(checkWindowClosure); | ||
| }; | ||
| cleanupRef.current = cleanup; | ||
| }, | ||
| [oauthUrlMap, crossmintAuth, setError] | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a popup's redemption remains pending and the user starts another provider flow,
handleRefreshAuthMaterialstores the superseded flow's tokens and invokes its refresh callback beforeownsPopup()runs, causing the abandoned provider to publish shared authentication state.Prompt To Fix With AI