Skip to content

bug: ChannelInner can call methods on a disconnected channel, causing uncaught errors #2393

@wchargin

Description

@wchargin

Describe the bug

Repeatedly, quickly mount and unmount a component that instantiates a <Chat /> and connects on mount, then disconnects on unmount. Most of the time, this works fine. About 1–2% of the time, an unhandled error "You can't use a channel after client.disconnect() was called" is thrown, and the chat window no longer loads properly.

To Reproduce

See description above. I don't have a fully self-contained repro. I can share the structure of our code (below), but the main evidence that I have is the following stack trace:

Error:
    <anonymous> debugger eval code:1
    _callee32$ browser.es.js:3269
    Babel 11
    query browser.es.js:3303
    _callee28$ browser.es.js:2920
    Babel 9
    watch browser.es.js:2943
    getChannel getChannel.js:42
    step tslib.es6.mjs:147
    verb tslib.es6.mjs:128
    __awaiter tslib.es6.mjs:121
    __awaiter tslib.es6.mjs:117
    getChannel getChannel.js:18
    ChannelInner Channel.js:234
    step tslib.es6.mjs:147
    verb tslib.es6.mjs:128
    __awaiter tslib.es6.mjs:121
    __awaiter tslib.es6.mjs:117
    ChannelInner Channel.js:207
    ChannelInner Channel.js:277
    React 9
    workLoop scheduler.development.js:266
    flushWork scheduler.development.js:239
    performWorkUntilDeadline scheduler.development.js:533

I captured this stack trace with my debugger paused on the channel.ts error site, "You can't use a channel after client.disconnect() was called". The line Channel.js:207 corresponds to somewhere in this Babel-compiled code…

$ nl node_modules/stream-chat-react/dist/components/Channel/Channel.js | sed -n 203,210p
   203      // useLayoutEffect here to prevent spinner. Use Suspense when it is available in stable release
   204      useLayoutEffect(function () {
   205          var errored = false;
   206          var done = false;
   207          (function () { return __awaiter(void 0, void 0, void 0, function () {
   208              var members, _i, _a, member, userId, _b, user, user_id, config, e_2, _c, user, ownReadState;
   209              var _d, _e, _f, _g;
   210              return __generator(this, function (_h) {

…which, from the getChannel call, must be this line:

https://github.com/GetStream/stream-chat-react/blob/v11.8.0/src/components/Channel/Channel.tsx#L566

I don't see a clean fix that my code could employ to avoid this, since we're always passing in the same client, and the point where we're disconnecting is on unmount so we don't have the ability to do much else. We work around it for now by delaying 5000ms between unmounting and actually disconnecting, but this is undesirable for lots of obvious reasons. I think that stream-chat-react should guard its calls to channel/client methods to ensure that it does not call any when the client might be disconnected.

Sample code structure
export const Main: React.FC<Props> = ({ channelName, className, user, token }) => {
  const { current: waitGroup } = useRef(new WaitGroup());

  const [streamClient, setStreamClient] = useState<StreamChat | undefined>();
  useEffect(() => {
    // connect websocket once and only once
    let streamClient: StreamChat;
    const ac = new AbortController();
    (async () => {
      try {
        streamClient = new StreamChat(STREAM_API_KEY);
        await streamClient.connectUser({ id: token.userId, name: user.name }, token.token);
        if (ac.signal.aborted) {
          return;
        }
        setStreamClient(streamClient);
      } catch (error: any) {
        if ("StatusCode" in error) {
          console.error(String((error as APIErrorResponse).StatusCode));
        }
      }
    })();

    // disconnect websocket on unmount
    return () => {
      ac.abort();
      (async () => {
        await sleepMs(5 * 1000);  // XXX: needed to work around this issue!
        await waitGroup.wait(10 * 1000);
        streamClient?.disconnectUser();
      })();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const channel = useMemo(() => {
    if (!streamClient?.user) {
      return;
    }
    return streamClient.channel("team", channelName, {
      name: channelName,
    });
  }, [channelName, t, streamClient]);

  useEffect(() => {
    if (channel) {
      waitGroup.add(channel.watch());
      return () => {
        if (!channel.disconnected) {
          waitGroup.add(channel.stopWatching());
        }
      };
    }
  }, [channel, waitGroup /* constant */]);

  if (!streamClient || streamClient.wsConnection?.isConnecting) {
    return "Loading...";
  }

  return (
    <Chat client={streamClient}>
      <Channel
        channel={channel}
        reactionOptions={OUR_REACTION_OPTIONS}
        UnreadMessagesNotification={UnreadMessagesNotification}
        UnreadMessagesSeparator={UnreadMessagesSeparator}
      >
        <Window>
          <VirtualizedMessageList
            Message={OurMessageComponent}
            shouldGroupByUser
          />
          <MessageInput grow />
        </Window>
        <Thread Message={OurMessageComponent} />
      </Channel>
    </Chat>
  );
};

class WaitGroup {
  constructor() {
    this._pending = new Set();
  }

  add<T>(promise: Promise<T>): Promise<T> {
    promise = Promise.resolve(promise); // defensive, in case of type-corrupted non-promise value
    const ref = new WeakRef(promise);
    this._pending.add(ref);
    return promise.finally(() => this._pending.delete(ref));
  }

  async wait(): Promise<void> {
    // Defer a frame so that any promises that are added to the wait group this
    // event loop get properly awaited. Particularly useful since execution
    // order of React `useEffect` cleanup handlers is undefined by design:
    // https://github.com/facebook/react/issues/16728#issuecomment-584208473
    await sleepMs(0);

    const pending = Array.from(this._pending, (weakRef) => weakRef.deref());
    await Promise.race([sleepMs(timeoutMs), Promise.allSettled(pending)]);
  }
}

Expected behavior

A rendered <Channel channel={channel} /> should never throw an internal error due to using a closed client.

Screenshots

Screenshot of the stack trace described above

Package version

  • stream-chat-react: v11.8.0
  • stream-chat-css: N/A? (not in my package-lock.json)
  • stream-chat-js: v8.16.0

Desktop (please complete the following information):

  • OS: macOS Sonoma 14.3
  • Browser: Firefox
  • Version: 126.0

Smartphone (please complete the following information):

  • Device: N/A
  • OS: N/A
  • Browser: N/A
  • Version: N/A

Additional context

N/A

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcluster:api-clientIssues related to use of API clientstatus:confirmedDescribed issue has been reproduced by the repo maintainer

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions