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

useSubscription stop working in React v18 StrictMode #9664

Closed
pobch opened this issue May 3, 2022 · 6 comments · Fixed by #9707
Closed

useSubscription stop working in React v18 StrictMode #9664

pobch opened this issue May 3, 2022 · 6 comments · Fixed by #9707

Comments

@pobch
Copy link
Contributor

pobch commented May 3, 2022

Intended outcome:

In React v18 StrictMode, data should be received from useSubscription().

Actual outcome:

data is always undefined even though the server actually sends it via websocket. Find more details in "How to reproduce the issue" below.

How to reproduce the issue:

  1. Use React v18
  2. Turn on StrictMode
  3. The complete code should look like this:
import React from 'react'
import ReactDOM from 'react-dom/client'
import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  HttpLink,
  split,
  useSubscription,
  gql,
} from '@apollo/client'
import { WebSocketLink } from '@apollo/client/link/ws'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import { getMainDefinition } from '@apollo/client/utilities'

const INSTRUMENTS_SUBSCRIPTION = gql`
  subscription OnInstrumentsUpdated {
    instruments {
      ...
    }
  }
`

// ----------------------------- Component ---------------------------------
function App() {
  const { data, loading } = useSubscription(INSTRUMENTS_SUBSCRIPTION, {
    fetchPolicy: 'network-only',
  })

  if (loading) return <p>Loading...</p>

  return (
    <>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </>
  )
}

// ----------------------------- Setup Apollo ---------------------------------
const httpLink = new HttpLink({
  uri: 'http://localhost:8088/api/graphql',
})

const wsLink = new WebSocketLink(new SubscriptionClient('ws://localhost:8088/ws/graphql'))

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
  },
  wsLink,
  httpLink
)

const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
})

// ----------------------------- React v18 StrictMode ---------------------------------
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
)
  1. Start the app
  2. In App component, loading will be false, and data will always be undefined.
  3. In the network tab, it shows that Apollo immediately sends { type: "stop" } message right after { type: "start" }

Screen Shot 2565-05-03 at 18 18 01

If I remove <React.StrictMode> from the code, the app would work properly. We got data from the server, and { type: "stop" } message would not be fired.

Screen Shot 2565-05-03 at 18 21 45

Versions

  System:
    OS: macOS 12.3.1
  Binaries:
    Node: 16.14.2 - /usr/local/bin/node
    Yarn: 1.22.18 - ~/.yarn/bin/yarn
    npm: 8.8.0 - /usr/local/bin/npm
  Browsers:
    Chrome: 100.0.4896.127
    Firefox: 99.0
    Safari: 15.4
  npmPackages:
    @apollo/client: ^3.6.2 => 3.6.2

Dependencies in package.json

  "dependencies": {
    "@apollo/client": "^3.6.2",
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^13.0.0",
    "@testing-library/user-event": "^13.2.1",
    "graphql": "^16.4.0",
    "react": "^18.1.0",
    "react-dom": "^18.1.0",
    "react-scripts": "5.0.1",
    "subscriptions-transport-ws": "^0.11.0",
    "web-vitals": "^2.1.0"
  },
@maltesa
Copy link

maltesa commented May 6, 2022

I'm experiencing the same issue in strictmode.

@chegger
Copy link

chegger commented May 9, 2022

#7608 seems to be a related issue

@kazekyo
Copy link
Contributor

kazekyo commented May 10, 2022

I have created a repository that reproduces this issue.
https://github.com/kazekyo/react-apollo-error-template/tree/strict_mode_subscription

I think it is related to restoring the state when StrictMode is enabled.

To help surface these issues, React 18 introduces a new development-only check to Strict Mode. This new check will automatically unmount and remount every component, whenever a component mounts for the first time, restoring the previous state on the second mount.

https://reactjs.org/blog/2022/03/29/react-v18.html#new-strict-mode-behaviors

useSubscription.ts

  1. Mount a component
  2. Create an observable
  3. Unmount and call subscription.unsubscribe()
  4. Re-mount and the state is restored, so the observable is reused

If I have more time today, I will check the Apollo Client source code.

@kazekyo
Copy link
Contributor

kazekyo commented May 10, 2022

useSubscription.ts

Once a subscription is created by observable.subscribe() is unsubscribed, it seems not to work when the subscription is created again from the observable.subscribe().
By commenting out subscription.unsubscribe(), useSubscription works correctly.

I don't understand why reusing an observable would cause such a problem.


A commit for debugging:
https://github.com/kazekyo/apollo-client/blob/0b055e28fa8b202ad1e9484a4eceb2ce921a3bc5/src/react/hooks/useSubscription.ts#L122-L135

StrictMode
スクリーンショット 2022-05-10 21 03 49

Removed StrictMode
スクリーンショット 2022-05-10 21 05 51
From this log, I guess it is working because subscription.unsubscribe() is not called when StrictMode is removed.

I remove StrictMode and manually remount the component, useSubscription still works. Logs are similar to StrictMode case, but observable is not reused.
スクリーンショット 2022-05-10 21 32 05

@kazekyo
Copy link
Contributor

kazekyo commented May 11, 2022

I understand why we should not reuse observable.

When useSubscription creates an observable, the observable adds a first observer. By subscribing to this observer, our program can communicate with our server.
Basically, when adding an observer, the observable increments addObserver but does not increment addObserver the first time it adds the observer.

const concast = new Concast([
execute(link, operation) as Observable<FetchResult<T>>
]);
byVariables.set(varJson, observable = concast);
concast.cleanup(() => {
if (byVariables.delete(varJson) &&
byVariables.size < 1) {
inFlightLinkObservables.delete(serverQuery);
}
});

const count = this.addCount;
this.addObserver(observer);
// Normally addObserver increments this.addCount, but we can "hide"
// cleanup observers by restoring this.addCount to its previous value
// after adding any cleanup observer.
this.addCount = count;

When React unmounts a component, useSubscription unsubscribes the created subscription. And observable decrements addObserver from 1 to 0. When setting addObserver to 0, the observable calls this.handlers.complete() and the first observer calls complete() finally.

subscription.unsubscribe();

if (this.observers.delete(observer) &&
--this.addCount < 1 &&
!quietly) {
// In case there are still any cleanup observers in this.observers, and no
// error or completion has been broadcast yet, make sure those observers
// have a chance to run and then remove themselves from this.observers.
this.handlers.complete();

iterateObserversSafely(this.observers, "complete");

Even if useSubscription subscribes to anything after that, our program can't communicate with the server because the observable does not have the first observer.

I think it is easier to recreate the observable when re-mounted than to rewrite the observable to be reusable.

I'm not familiar with zen-observable and Apollo Client code, so please let me know if there is something wrong 🙏

kazekyo added a commit to kazekyo/apollo-client that referenced this issue May 11, 2022
React18 automatically unmounts and remounts components in StrictMode. When React remount a component, the previous state is restored. However, if the previous state is reused, the subscription will not work.
So we should recreate the necessary object when React remounts the component.
@benjamn benjamn added this to the v3.6.x patch releases milestone May 13, 2022
benjamn pushed a commit to kazekyo/apollo-client that referenced this issue May 16, 2022
React18 automatically unmounts and remounts components in StrictMode. When React remount a component, the previous state is restored. However, if the previous state is reused, the subscription will not work.
So we should recreate the necessary object when React remounts the component.
benjamn added a commit that referenced this issue May 16, 2022
Fix useSubscription bug in React v18 <StrictMode> (#9664)
@jurgelenas
Copy link

Copying my comment from #9707 (comment):

I think apollo-client has an issue when re-mounting subscription components without the StrictMode now.

This issue is reproducible on our open source desktop app ExpressLRS Configurator. We were able to fix subscription re-mount issues in development build by wrapping whole app inside <React.StrictMode> component. See ExpressLRS/ExpressLRS-Configurator#351. But nightly builds are still not working at https://github.com/ExpressLRS/ExpressLRS-Configurator-Nightlies.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants