Skip to content
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

Can't close fetchEventSource with AbortController in React #84

Open
LachlanMarnham opened this issue Jul 9, 2024 · 6 comments
Open

Comments

@LachlanMarnham
Copy link

Hello. I'm aware this topic has been raised many times here already, and I have read all of those issues with no success. Indeed, in most cases those issues have comments from people saying the proposed solution didn't work for them either. I am running my app in React.StrictMode, which causes each component to mount/unmount/remount to help identify unexpected side-effects and so on. In my case this is causing two fetchEventSource requests to be fired, which is expected. What's unexpected, is that using an AbortController does not close the first connection on unmount. This is my hook:

const useStreamTradingNotifications = () => {
  const [ abortController, setAbortController ] = useState(new AbortController());
  useEffect(() => {
    console.log("useEffect called");
    const stream = async () => {
        await fetchEventSource("http://127.0.0.1:8000/v1/notifications", {
          signal: abortController.signal,
          onmessage(msg) {
            if (abortController.signal.aborted){
              console.log("still getting messages from aborted signal")
            }
            console.log(`event: ${msg.event}`)
            if (msg.event == "update") {
              console.log(JSON.parse(msg.data))
            }
          },
        });
    };
    stream();
    return () => {
      console.log("abort called")
      
      abortController.abort();
      setAbortController(new AbortController());
    }
  }, []);
};

When I run my app, I see the following logs:
image

As you can see:

  1. useEffect is called twice, as the component is mounted/unmounted/remounted [expected]
  2. abortController.abort is called when the first component unmounts [expected]
  3. Subsequently I receive two of each message (the logs above are truncated, and just includes the first duplicate). For one of the two streams, I can confirm that the messages are being received on an already-aborted connection (still getting messages from aborted signal).

Furthermore, in my backend logs, when I close a browser window with an active connection I see a log like:

[2024-07-09T11:13:37.409247Z][sse_starlette.sse][DEBUG] Got event: http.disconnect. Stop streaming.

But I never see this message as the result of AbortController.abort being called. I have tried assigning a new instance of AbortController after abort (as suggested here), which had no affect. I have tried doing a similar thing with setState (as mentioned here) which made the problem much worse (kept creating connections until the browser crashed). I tried to use useRef to manage the controller (suggested here, which also doesn't work.

The fact that I can see abort being called, but never see a http.disconnect on the backend, convinces me this must be a bug. But if anybody has a suspicion that I've screwed something up I'd really appreciate their suggestion.

Many thanks

@daimalou
Copy link

daimalou commented Sep 2, 2024

You should use useRef to store a AbortController instance, this is the React part.

@daimalou
Copy link

daimalou commented Sep 2, 2024

  const useStreamTradingNotifications = () => {
    const abortControllerRef = useRef();
    useEffect(() => {
      console.log("useEffect called");
      const stream = async () => {
        abortControllerRef.current = new AbortController();
        await fetchEventSource("http://127.0.0.1:8000/v1/notifications", {
          signal: abortControllerRef.current.signal,
          onmessage(msg) {
            if (abortControllerRef.current.signal.aborted) {
              console.log("still getting messages from aborted signal");
            }
            console.log(`event: ${msg.event}`);
            if (msg.event == "update") {
              console.log(JSON.parse(msg.data));
            }
          },
        });
      };
      stream();
      return () => {
        console.log("abort called");
        if (abortControllerRef.current) {
          abortControllerRef.current.abort();
        }
      };
    }, []);
  };

@daimalou
Copy link

daimalou commented Sep 2, 2024

ref.current will not change in rerender, so it is the same instance.

@bflemi3
Copy link

bflemi3 commented Dec 11, 2024

const useStreamTradingNotifications = () => {
const abortControllerRef = useRef();
useEffect(() => {
console.log("useEffect called");
const stream = async () => {
abortControllerRef.current = new AbortController();
await fetchEventSource("http://127.0.0.1:8000/v1/notifications", {
signal: abortControllerRef.current.signal,
onmessage(msg) {
if (abortControllerRef.current.signal.aborted) {
console.log("still getting messages from aborted signal");
}
console.log(event: ${msg.event});
if (msg.event == "update") {
console.log(JSON.parse(msg.data));
}
},
});
};
stream();
return () => {
console.log("abort called");
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
};

@daimalou does this actually work for you? This doesn't work for me and I follow the same pattern.

@daimalou
Copy link

@bflemi3 This is not my code. just illustrate, when the react component is unmounted, the SSE connection will close.
Here is my real project code.

  const abortControllerRef = useRef();

  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

const startSSE = async () => {
  abortControllerRef.current = new AbortController();
  await fetchEventSource(url, {
    method: 'GET',
    signal: abortControllerRef.current.signal,
    // ...
  }
}

@bflemi3
Copy link

bflemi3 commented Dec 13, 2024

I had to do something a little different. If my useStream hook was unmounted before the onopen handle fired, the connection would still persist. So here's what I did...

import { useAuth0 } from '@auth0/auth0-react'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useCallback, useEffect, useRef, useState } from 'react'

import useReffed from './useReffed'

export enum ReadyState {
  UNINSTANTIATED = -1,
  CONNECTING = 0,
  OPEN = 1,
  CLOSED = 2,
}

export interface UseStreamOptions<T = unknown> {
  onClose?: VoidFunction
  onError?: (event: Event) => void
  onMessage: (data: T) => void
  onOpen?: (event: Response) => void
}

class AbortedError extends Error {}

export default function useStream<T = unknown>(
  url: string,
  { onClose, onError, onMessage, onOpen }: UseStreamOptions<T>
) {
  const abortControllerRef = useRef<AbortController>(undefined)
  const { getAccessTokenSilently } = useAuth0()

  // Event Listener Refs
  const onCloseRef = useReffed(onClose)
  const onErrorRef = useReffed(onError)
  const onMessageRef = useReffed(onMessage)
  const onOpenRef = useReffed(onOpen)

  // State
  const [error, setError] = useState(false)
  const [readyState, setReadyState] = useState<ReadyState>(ReadyState.UNINSTANTIATED)

  useEffect(() => {
    if (!url) {
      return
    }

    const abortController = (abortControllerRef.current = new AbortController())

    ;(async () => {
      try {
        setReadyState(ReadyState.CONNECTING)

        const token = await getAccessTokenSilently()

        await fetchEventSource(url, {
          headers: { Authorization: `Bearer ${token}` },
          signal: abortController.signal,
          onclose: () => {
            setReadyState(ReadyState.CLOSED)

            const onClose = onCloseRef.current
            onClose && onClose()
          },
          onerror: (e) => {
            setError(true)
            setReadyState(ReadyState.CLOSED)

            const onError = onErrorRef.current
            onError && onError(e)

           // If an error occurs we want to throw to abort the connection
            throw e
          },
          onmessage: (event) => {
            onMessageRef.current(data)
          },
          onopen: async (response) => {
            // If the hook was unmounted or disconnected called before this handler, then
            // throw the AbortedError to disconnect.
            if (abortController.signal.aborted) {
              throw new AbortedError()
            }

            setReadyState(ReadyState.OPEN)
            setError(false)

            const onOpen = onOpenRef.current
            onOpen && onOpen(response)
          },
        })
      } catch (error) {
        if (error instanceof AbortedError) {
          setReadyState(ReadyState.CLOSED)
        } else {
          setError(true)
        }
      }
    })()

    return () => {
      abortController.abort()
    }
  }, [url])

  const disconnect = useCallback(() => {
    abortControllerRef.current.abort()
    setReadyState(ReadyState.CLOSED)
  }, [])

  return { error, readyState, disconnect }
}

useReffed.ts

import { useRef } from 'react'

export default function useReffed<T>(obj: T) {
  const ref = useRef(obj)
  ref.current = obj

  return ref
}

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

No branches or pull requests

3 participants