@@ -37,18 +37,64 @@ interface UseMcpOauthPopupProps {
3737 workspaceId : string
3838}
3939
40+ /**
41+ * Bounds how long a row shows "Connecting…" without a result. Matches the server-side OAuth
42+ * start TTL: once it lapses the authorization state has expired and the flow can no longer
43+ * complete, so a still-pending flow is safe to drop.
44+ */
45+ const OAUTH_FLOW_TIMEOUT_MS = 10 * 60 * 1000
46+
4047export function useMcpOauthPopup ( { workspaceId } : UseMcpOauthPopupProps ) {
4148 const queryClient = useQueryClient ( )
4249 const { mutateAsync : startOauth } = useStartMcpOauth ( )
4350
4451 const [ connectingServers , setConnectingServers ] = useState < Set < string > > ( ( ) => new Set ( ) )
45- const popupIntervalsRef = useRef < Map < string , number > > ( new Map ( ) )
52+ // serverId -> safety timeout. This is the correlation source of truth for the
53+ // BroadcastChannel gate: it is cleared only when the flow completes or times out, never by
54+ // popup.closed polling — COOP can make popup.closed misreport, and clearing it early would
55+ // drop the genuine completion this fix exists to deliver.
56+ const pendingFlowsRef = useRef < Map < string , number > > ( new Map ( ) )
57+ // serverId -> popup.closed poll. Best-effort fast "Connecting…" clear when the user
58+ // abandons the popup; never used to correlate a result.
59+ const popupPollsRef = useRef < Map < string , number > > ( new Map ( ) )
60+
61+ const stopConnecting = useCallback ( ( serverId : string ) => {
62+ setConnectingServers ( ( prev ) => {
63+ if ( ! prev . has ( serverId ) ) return prev
64+ const next = new Set ( prev )
65+ next . delete ( serverId )
66+ return next
67+ } )
68+ } , [ ] )
69+
70+ const stopPopupPoll = useCallback ( ( serverId : string ) => {
71+ const poll = popupPollsRef . current . get ( serverId )
72+ if ( poll !== undefined ) {
73+ window . clearInterval ( poll )
74+ popupPollsRef . current . delete ( serverId )
75+ }
76+ } , [ ] )
77+
78+ /** End a flow entirely: stop the spinner, its safety timeout, and its popup poll. */
79+ const settleFlow = useCallback (
80+ ( serverId : string ) => {
81+ const timeout = pendingFlowsRef . current . get ( serverId )
82+ if ( timeout !== undefined ) window . clearTimeout ( timeout )
83+ pendingFlowsRef . current . delete ( serverId )
84+ stopPopupPoll ( serverId )
85+ stopConnecting ( serverId )
86+ } ,
87+ [ stopConnecting , stopPopupPoll ]
88+ )
4689
4790 useEffect ( ( ) => {
48- const intervals = popupIntervalsRef . current
91+ const pending = pendingFlowsRef . current
92+ const polls = popupPollsRef . current
4993 return ( ) => {
50- for ( const id of intervals . values ( ) ) window . clearInterval ( id )
51- intervals . clear ( )
94+ for ( const t of pending . values ( ) ) window . clearTimeout ( t )
95+ for ( const p of polls . values ( ) ) window . clearInterval ( p )
96+ pending . clear ( )
97+ polls . clear ( )
5298 }
5399 } , [ ] )
54100
@@ -61,90 +107,64 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
61107 channel . onmessage = ( event ) => {
62108 const data = event . data as Partial < McpOauthCallbackMessage > | null
63109 if ( data ?. type !== 'mcp-oauth' ) return
64- // A BroadcastChannel reaches every same-origin tab, so only the tab that actually
65- // opened this flow's popup should react — otherwise an unrelated tab (any workspace)
66- // would clear state, refetch, and show a spurious toast. `popupIntervalsRef` holds
67- // this tab's in-flight popups; a result for a server it never opened is not ours.
68- const initiatedHere = data . serverId
69- ? popupIntervalsRef . current . has ( data . serverId )
70- : popupIntervalsRef . current . size > 0
71- if ( ! initiatedHere ) return
72- if ( data . serverId ) {
73- const serverId = data . serverId
74- const interval = popupIntervalsRef . current . get ( serverId )
75- if ( interval !== undefined ) {
76- window . clearInterval ( interval )
77- popupIntervalsRef . current . delete ( serverId )
78- }
79- setConnectingServers ( ( prev ) => {
80- if ( ! prev . has ( serverId ) ) return prev
81- const next = new Set ( prev )
82- next . delete ( serverId )
83- return next
84- } )
85- } else if ( ! data . ok ) {
86- // Early callback failures (missing params, invalid state) post back
87- // without a serverId, so we can't target a specific row — clear all
88- // in-flight popups instead of leaving the UI stuck on "Connecting…".
89- for ( const id of popupIntervalsRef . current . values ( ) ) window . clearInterval ( id )
90- popupIntervalsRef . current . clear ( )
91- setConnectingServers ( ( prev ) => ( prev . size === 0 ? prev : new Set ( ) ) )
92- }
110+ // A BroadcastChannel reaches every same-origin tab, so react only to a result for a
111+ // flow THIS tab started. A result without a serverId can't be correlated to a flow, so
112+ // it's ignored here — the initiating tab's safety timeout clears its own "Connecting…".
113+ if ( ! data . serverId || ! pendingFlowsRef . current . has ( data . serverId ) ) return
114+ settleFlow ( data . serverId )
93115 if ( data . ok ) {
94116 queryClient . invalidateQueries ( { queryKey : mcpKeys . serversList ( workspaceId ) } )
95- if ( data . serverId ) {
96- queryClient . invalidateQueries ( {
97- queryKey : mcpKeys . serverToolsList ( workspaceId , data . serverId ) ,
98- } )
99- } else {
100- queryClient . invalidateQueries ( {
101- queryKey : mcpKeys . serverToolsWorkspace ( workspaceId ) ,
102- } )
103- }
117+ queryClient . invalidateQueries ( {
118+ queryKey : mcpKeys . serverToolsList ( workspaceId , data . serverId ) ,
119+ } )
104120 queryClient . invalidateQueries ( { queryKey : mcpKeys . storedToolsList ( workspaceId ) } )
105121 toast . success ( 'Server authorized' )
106122 } else {
107123 toast . error ( reasonToMessage ( data . reason ) )
108124 }
109125 }
110126 return ( ) => channel . close ( )
111- } , [ queryClient , workspaceId ] )
127+ } , [ queryClient , workspaceId , settleFlow ] )
112128
113129 const startOauthForServer = useCallback (
114130 async ( serverId : string ) => {
115131 setConnectingServers ( ( prev ) => new Set ( prev ) . add ( serverId ) )
116- const clear = ( ) => {
117- const existing = popupIntervalsRef . current . get ( serverId )
118- if ( existing !== undefined ) {
119- window . clearInterval ( existing )
120- popupIntervalsRef . current . delete ( serverId )
121- }
122- setConnectingServers ( ( prev ) => {
123- const next = new Set ( prev )
124- next . delete ( serverId )
125- return next
126- } )
127- }
128132 try {
129133 const result = await startOauth ( { serverId, workspaceId } )
130134 if ( result . status === 'already_authorized' ) {
131- clear ( )
135+ settleFlow ( serverId )
132136 return
133137 }
134138 const { popup } = result
135- const existing = popupIntervalsRef . current . get ( serverId )
136- if ( existing !== undefined ) window . clearInterval ( existing )
137- const interval = window . setInterval ( ( ) => {
138- if ( popup . closed ) clear ( )
139- } , 500 )
140- popupIntervalsRef . current . set ( serverId , interval )
139+ // Track this in-flight flow for the BroadcastChannel gate, bounded by a safety
140+ // timeout in case no result ever arrives (popup abandoned, or a callback failure
141+ // that couldn't carry a serverId).
142+ const existingTimeout = pendingFlowsRef . current . get ( serverId )
143+ if ( existingTimeout !== undefined ) window . clearTimeout ( existingTimeout )
144+ pendingFlowsRef . current . set (
145+ serverId ,
146+ window . setTimeout ( ( ) => settleFlow ( serverId ) , OAUTH_FLOW_TIMEOUT_MS )
147+ )
148+ // Best-effort: clear "Connecting…" quickly when the user closes the popup without
149+ // finishing. popup.closed can misreport under COOP, so this only stops the spinner —
150+ // it never touches `pendingFlowsRef`, so it can't drop a real result.
151+ stopPopupPoll ( serverId )
152+ popupPollsRef . current . set (
153+ serverId ,
154+ window . setInterval ( ( ) => {
155+ if ( popup . closed ) {
156+ stopPopupPoll ( serverId )
157+ stopConnecting ( serverId )
158+ }
159+ } , 500 )
160+ )
141161 } catch ( e ) {
142- clear ( )
162+ settleFlow ( serverId )
143163 logger . error ( 'Failed to start MCP OAuth' , e )
144164 toast . error ( toError ( e ) . message || 'Failed to start authorization' )
145165 }
146166 } ,
147- [ startOauth , workspaceId ]
167+ [ startOauth , workspaceId , settleFlow , stopConnecting , stopPopupPoll ]
148168 )
149169
150170 return { connectingServers, startOauthForServer }
0 commit comments